Ryan Sanna

Writing

Prerendering a Hand-Rolled SPA with React 19

This site's router is about forty lines: useSyncExternalStore over location.pathname, a navigate() that pushes history state, and a Link that intercepts plain left-clicks. No framework, no route config, no loader API. The catch with any client-routed site is that crawlers and link-preview bots get an empty <div id="root"> — the article you are reading would be invisible to anything that doesn't run JavaScript.

The usual fix is adopting a meta-framework. The lighter fix is about a hundred lines at build time.

React 19 does the hard part

The classic obstacle to do-it-yourself prerendering is React.lazy: article bodies here are code-split, and the old renderToString renders suspended content as its fallback — you'd ship "Loading…" to every crawler. React 19's react-dom/static exposes prerender(), which waits for Suspense boundaries to resolve before producing HTML. Lazy chunks just work:

import { prerender } from 'react-dom/static';

setSsrPath(path);                          // server snapshot for the router
const { prelude } = await prerender(<App />);
const html = await streamToString(prelude); // full article body, no fallbacks

The router needed one addition: useSyncExternalStore takes a third argument — the server snapshot — so at build time each route renders as if the browser were already there. The client entry then calls hydrateRoot when the root has children and createRoot in dev, where the shell is empty.

One SSR bundle, one postbuild script

Vite builds the same app twice: the normal client build, then vite build --ssr on a tiny entry that exports renderRoute(path) plus route metadata derived from the article registry. A postbuild script loops over the routes, injects each rendered tree into the built index.html, and rewrites the title, description, canonical, and Open Graph tags per route. The same registry emits rss.xml and sitemap.xml while it's there. Publishing an article is still one component file and one registry entry — the feed, sitemap, and static HTML all follow from that.

The CDN has to cooperate

Prerendered routes are files like writing/some-post/index.html, but a request for /writing/some-post still needs to resolve to that directory index. Cloudflare Worker Static Assets handles clean HTML paths at the edge, so the prerendered document wins when it exists. Unknown navigation paths use the configured SPA fallback, where the client router can render not-found.

Wrangler uploads a content-addressed asset version and promotes it as one Worker deployment. The result is a static site that happens to hydrate into an app — crawlers get real HTML, browsers get a voxel town, and the whole arrangement is small enough to read in one sitting.