Adding SSG to a Vite + React SPA that works

Prerender a Vite + React 19 SPA so React hydrates and keeps your markup

Your prerendered HTML is probably being thrown in the bin

How to add SSG to a Vite + React SPA so React adopts your markup instead of destroying it. And why React.lazy is quietly undoing all your work.

I spent far too long adding static prerendering to a React SPA. Build script, static handler, snapshots on disk, the lot. I opened the built HTML and there it was. Real content, real markup, exactly the page I wanted.

Then I ran a Lighthouse trace and the LCP hadn’t moved. Not a bit.

The HTML was perfect. The browser painted it. And then React booted up, took one look at what I’d prerendered, decided it didn’t match, threw the entire DOM away and rebuilt the page from scratch. My LCP element was destroyed and re-created. The page reflowed. I’d made it slower, and added a build step for the privilege.

The culprit turned out to be one line I’d have sworn was fine: const Customers = lazy(() => import('./Customers')).

This post is about the difference between hydration that adopts your markup and hydration that bins it. And the handful of non-obvious things standing between you and the first one. Vite, React 19, react-router. No framework.

The Problem: Two APIs, One Bin

Two React functions. The difference between them is the whole article.

createRoot(root).render(tree)   // "This container is mine. I'll clear it and build."
hydrateRoot(root, tree)         // "There's markup here already. I'll attach to it."

createRoot wipes #root and rebuilds it. Anything you prerendered into that div is destroyed. Your LCP element flashes out of existence and comes back, the browser reflows, and your LCP timestamp resets to after your bundle ran. Doing the same thing twice and slowing everything down.

hydrateRoot walks the existing DOM and attaches handlers to nodes that are already there. Nothing is destroyed. The element you painted at 400ms is the same node still on screen at 1,400ms. This is what we are looking for.

Fun fact,hydrateRoot is conditional. React compares its first render against what’s in the DOM. If they disagree, it bails and discards your markup. Then runs a full client render anyway. Silently degrading you right back to createRoot. There is a console.log, but this is easily missed.

How do we make this work correctly?

Why Not Next.js: I Need a Route Table, Not a Folder

If you’re building a content site or a server-rendered product then use Next. I use it for all of my marketing sites.

But the app that I am building out ships as an SPA inside a Capacitor webview on iOS and Android, and inside a Tauri window on macOS and Windows. There’s no Node server in front of it on any of those. And that rules out the framework for a reason that’s worth stating precisely, because it’s not the one people expect.

Two things get conflated constantly:

  • Dynamic params – [id], [...slug]. A statically declared route with a variable segment. Next handles these perfectly well. Not what I mean.
  • Dynamic URL routing – the route table itself is a data structure my code owns. Which routes exist, and which router resolves them, are decisions made in code.

The App Router gives you the first and structurally cannot give you the second. Routes are declared by the filesystem and resolved by the framework, so there’s no route table for me to pick up and pass somewhere. The folder structure is the table.

This entire article depends on being able to do exactly that. My route array gets handed to three different resolvers:

ConsumerResolverNotes
WebcreateBrowserRouterbasename /app
Capacitor (iOS/Android)createBrowserRouterbasename /app
Tauri (macOS/Windows)createHashRouterfragment-routed, no basename
Build time (this post)createStaticHandlerin Node, to prerender to HTML

That last row is the one that matters here. The reason my prerendered markup and my hydrated app cannot disagree about what a URL means. The guarantee the rest of this post is built on is that the static handler and the browser router are fed the same array. Not a mirrored config. Not a parallel convention. The same value, imported by both.

A filesystem can’t be passed as an argument. There’s no [...slug] that turns your app/ directory into a build-time static handler feeding a hash router in a Tauri window.

Could I get closer with output: 'export', or by falling back to Pages Router? Sure. But then I’m hauling a large framework around specifically to disable the parts of it I’m paying for, to arrive at… a static SPA. That’s where Vite starts.

And plainly: Vite is a materially faster dev experience. With five platforms booting off one dev server, that loop is where I live all day.

One Route Table, Two Consumers

Before anything else, the structural rule. Your route config has to be importable in Node. Which means it can’t construct a router.

// src/router.tsx - pure data. Node-safe. Touches no `document`.
export const routes = [
    {
        Component: RootLayout,
        children: [
            { index: true, Component: HomePage },
            { path: 'products', Component: Products },
            { path: 'customers', Component: Customers },
        ],
    },
]
// src/main.tsx - browser only.
const router = createBrowserRouter(routes, { basename: '/app' })

createBrowserRouter reads document at construction time. Leave it in router.tsx and your build script dies the moment it imports the route table.

Keep the table as inert data and you get the guarantee from the last section for free: one array, fed to the static handler at build time and the browser router at runtime. Server matching and client matching are byte-identical by construction.

That’s worth sitting with. Most hydration mismatch bugs are really “the server and the client disagreed about something.” Removing every opportunity for them to disagree is a lot cheaper than debugging the disagreements.

The Render Entry: Twenty Lines

This is what your build script calls, once per route.

// src/entry-server.tsx - build-time only, never shipped to the browser
export const render = async (routeKey: string): Promise<{ html: string }> => {
    // Resolve the route's code-split chunk FIRST - see the next section.
    await PRERENDERED_ROUTE_PRELOADS[routeKey]?.()
    const path = routeKey === '/' ? '' : routeKey
    const request = new Request(`http://localhost/app${path}`)
    const handler = createStaticHandler(routes, { basename: '/app' })
    const context = await handler.query(request)
    if (context instanceof Response) {
        throw new Error(`Route "${routeKey}" returned a Response (loader redirect?) - not prerenderable.`)
    }
    const staticRouter = createStaticRouter(handler.dataRoutes, context)
    return { html: renderToString(<StaticRouterProvider router={staticRouter} context={context} />) }
}

Two things to flag.

The basename must match on both sides. createStaticHandler(routes, { basename: '/app' }) and createBrowserRouter(routes, { basename: '/app' }). Get those out of sync and you’ll prerender a 404 page for every single route while everything looks fine.

Throw loudly on a Response. If a loader returns a redirect, react-router hands it back as a Response instead of markup – and if you don’t check, you’ll cheerfully write an empty HTML file and never know. A redirecting route isn’t prerenderable. Fail the build and say so.

React.lazy Not Playing Ball

Here’s the one that cost me the week, and the reason I wrote this post at all.

You code-split your routes. Obviously.

const Customers = lazy(() => import('~/app/Customers'))

So in the build script and in main.tsx, you’re careful. You await the dynamic import before rendering, so the module is definitely resolved. Then you render. And you still get a mismatch. And your DOM still gets binned.

Why?

React.lazy suspends on first render even when its promise has already resolved.

It doesn’t hold a synchronous reference to the module. On first render it re-attaches .then() to the (already-settled) promise and suspends for a microtask. So:

  • On the server, renderToString is synchronous. It hits the suspending component, gives up, and commits the nearest Suspense fallback. If your fallback is null, congratulations you just prerendered an empty div.
  • On the client, hydrateRoot‘s first pass hits the same fallback. It compares null against your prerendered page body, finds a mismatch, and throws the DOM away.

You did everything right and React quietly defeated you. Both failures are silent. No crash, no obvious error, just a build output that looks completely plausible and a page that’s no faster than it was.

lazyWithPreload = the twelve lines that fix it

type PreloadableComponent = ComponentType & { preload: () => Promise<unknown> }
const lazyWithPreload = (
    factory: () => Promise<{ default: ComponentType }>,
): PreloadableComponent => {
    let mod: { default: ComponentType } | undefined
    let promise: Promise<unknown> | undefined
    const load = (): Promise<unknown> =>
        (promise ??= factory().then((resolved) => {
            mod = resolved            // ← capture a synchronous reference
            return resolved
        }))
    const Preloaded = () => {
        if (!mod) {
            throw load()              // Suspense protocol: throw the promise
        }
        const Loaded = mod.default    // ← already here? render it. No suspension.
        return <Loaded />
    }
    return Object.assign(Preloaded, { preload: load })
}

The whole difference is that captured mod. Once preload() has settled, the component checks a plain variable synchronously and returns the real thing on its very first render. No suspension. No fallback. No mismatch. renderToString emits the real page body, hydrateRoot‘s first pass produces the same thing, and React adopts the DOM.

If the chunk hasn’t been preloaded, a client-side nav, or a platform that doesn’t prerender, it throws the load promise and suspends exactly like React.lazy. You lose nothing.

Then use it on the client too

// src/main.tsx
const preload = PRERENDERED_ROUTE_PRELOADS[routeKey(window.location.pathname)]
if (preload) {
    await preload()                       // resolve the chunk BEFORE hydrating
    hydrateRoot(root, tree, options)      // → adopts the prerendered DOM
    return
}
createRoot(root, options).render(tree)    // no snapshot here. A fresh render is correct

That await is the payoff. It delays hydration by exactly one already-in-flight module fetch, and in exchange React keeps the DOM it was handed instead of bulldozing it.

The fallback branch matters as much as the happy path, by the way. A route you didn’t prerender has an empty #root, nothing to adopt, so createRoot is genuinely correct. Same for my native builds: Capacitor and Tauri skip the prerender path entirely and go straight there.

Bonus: that .preload() handle is useful well beyond SSG. My settings layout fires all five sibling tab preloads on idle when any tab mounts, so switching tabs renders synchronously with no Suspense flash. Each preload() memoises its promise, so it shares the fetch with the router’s own render path. Never a double download.

The Build Script

Runs after vite build, so the client bundle and its HTML shell already exist.

"build": "vite build && node ./scripts/ssg.ts"

It does five things: read the built HTML shell, inline the CSS, boot Vite’s SSR loader, render each route, write the snapshots.

const PRERENDER_ROUTES = ['/', '/customers', '/products'] as const
const vite = await createServer({
    mode: 'production',          // ← see gotcha
    server: { middlewareMode: true },
    appType: 'custom',
})
const { render } = await vite.ssrLoadModule('/src/entry-server.tsx')
// Collect ALL snapshots in memory first - see gotcha.
const snapshots = new Map<string, string>()
for (const route of PRERENDER_ROUTES) {
    const { html } = await render(route)
    snapshots.set(route, injectIntoShell(template, route, html))
}
await vite.close()
for (const [route, html] of snapshots) {
    writeFileSync(routeOutputPath(route), html, 'utf8')
}

vite.ssrLoadModule is what makes this cheap. You get your app’s real module graph, TypeScript, path aliases, JSX, CSS imports, all of it transformed on the fly in Node, with no separate SSR build config. There’s no bundling step for the server. It just runs.

Four things will bite you here. All four bit me.

Gotcha 1: pass mode: 'production'. createServer defaults to development. Without this you render with development React verbose error templates, dev-only Suspense strings, markup that doesn’t match your production client bundle. You’ll ship dev React to real users and spend an evening wondering why hydration keeps mismatching.

Gotcha 2: collect every snapshot before writing any of them. The shell template you’re injecting into is dist/app/index.html. Write the home snapshot mid-loop and every subsequent route reads that as its template. Symptom: every page on your site has the homepage buried inside it. Render all, then write all.

Gotcha 3: React 19 emits <title> and <meta> inline in the body. React 19 lets you render document metadata anywhere in the tree, and hoists it into <head> in the browser. renderToString just leaves the tags where it found them, sitting in the body string. Your injector has to pull them out and place them before </head> itself, or your static files are invisible to crawlers. Which was half the point.

Gotcha 4: a route whose loader redirects isn’t prerenderable. Throw, rather than silently writing an empty file.

The metadata hoist, concretely:

const injectIntoShell = (template: string, route: string, bodyHtml: string): string => {
    const headTags: string[] = []
    const stripped = bodyHtml
        .replace(/<title[^>]*>[\s\S]*?<\/title>/gi, (m) => { headTags.push(m); return '' })
        .replace(/<meta\b[^>]*?\/?>/gi,             (m) => { headTags.push(m); return '' })
    const titleCount = headTags.filter((t) => /^<title/i.test(t)).length
    if (titleCount !== 1) {
        throw new Error(`Route "${route}" produced ${titleCount} <title> tags (expected 1).`)
    }
    return template
        .replace('<div id="root"></div>', `<div id="root">${stripped}</div>`)
        .replace('</head>', `${headTags.join('\n')}\n</head>`)
}

Yes, that’s regex over HTML. It runs against the output of one known serialiser, in a build step, with an assertion that fails the build if the shape is ever wrong. Ship it.

Inlining the CSS & Killing the Last Blocking Request

You’ve got real content in the HTML now, but the browser still won’t paint it until it’s downloaded your stylesheet. A <link rel="stylesheet"> is render-blocking. So there’s still a round-trip standing between your user and the content you worked so hard to put in the document.

Inline it:

const inlineStylesheet = (template: string, distDir: string): string =>
    template.replace(/<link\b[^>]*\brel="stylesheet"[^>]*>/gi, (tag) => {
        const href = /\bhref="([^"]+)"/i.exec(tag)?.[1]
        if (!href) return tag
        const css = readFileSync(resolve(distDir, href.replace(/^\/app\//, '')), 'utf8')
        return `<style>${css}</style>`
    })

Do it once, on the shared shell, before the per-route body injection.

For a Tailwind app the whole sheet is around 67KB raw,  12–15KB gzipped. It’s typically the only render-blocking resource on the page. Inlining removes the round-trip completely. First paint needs zero additional requests.

The obvious objection: why not extract critical CSS and load the rest async? I tried. Don’t. The async swap repaints the above-the-fold region when the rest lands, and a visible reflow on every cold load is worse than a slightly bigger HTML document.

And the trade is smaller than it looks. This is an SPA, one HTML load, then client-side navigation forever. So the sheet gets fetched once with the document either way. The only real cost is that it rides each cold HTML response instead of living in a separately-cached hashed asset.

Stable Hashes and Warming the Cache

Two smaller wins that compound with all of the above.

Name your vendor chunks. Left alone, Rollup’s chunking means an unrelated app change can shuffle hashes across your whole output, invalidating caches that had no business being invalidated. Bucket them explicitly:

manualChunks(id) {
    const buckets = {
        'vendor-react':  ['react', 'react-dom'],
        'vendor-router': ['react-router'],
        'ui-headless':   ['@heroicons/react'],
    }
    for (const [name, deps] of Object.entries(buckets)) {
        if (deps.some((d) => id.includes(`node_modules/${d}/`))) return name
    }
}

Keep genuinely heavy, genuinely optional things (a date-picker library, say) in their own bucket so they never sneak into the first-paint graph.

Warm the app’s chunks from your marketing site. If your app lives at /app/ on the same origin as a marketing page, have the build emit a small script that reads Vite’s manifest, walks the import graph for your entry plus the landing route, and appends <link rel="modulepreload"> for each chunk on idle.

Drop that on your marketing page. By the time a visitor clicks “Get started,” the app’s entry chunk and its vendor graph are already sitting in their HTTP cache. Combine that with a prerendered landing route that paints instantly and hydrates without rebuilding, and the click-through feels like a page that was already open.

The Compound Effect

Before: blank page → JS downloads → JS parses → React mounts → paint. LCP lands whenever your bundle finishes executing.

After: HTML arrives with real content and inlined styles → paint immediately, zero extra requests → bundle loads in the background → the matched route’s chunk resolves → React hydrates and adopts the DOM it was handed.

The element the user saw at first paint is the same DOM node they’re looking at when the app goes interactive. Nothing flashes. Nothing reflows. LCP is the static paint, not the bundle.

And because it’s a build script that only touches the web build, my native shells just skip it. Same route table, same components, same everything – they take the createRoot path and cold-load the SPA exactly as before.

The Checklist

  • [ ] Route table is pure data, importable in Node – no createBrowserRouter in it
  • [ ] The same basename on createStaticHandler and createBrowserRouter
  • [ ] Prerendered routes use a sync-render preload wrapper, not React.lazy
  • [ ] The chunk is awaited before renderToString and before hydrateRoot
  • [ ] Routes without a snapshot fall back to createRoot
  • [ ] createServer({ mode: 'production' }) in the build script
  • [ ] All snapshots collected in memory, then written
  • [ ] <title>/<meta> hoisted out of the body into <head> (React 19)
  • [ ] Exactly one <title> per route – assert it and fail the build
  • [ ] Redirecting loaders throw instead of writing an empty file
  • [ ] Stylesheet inlined into <head>, <link> removed
  • [ ] Vendor chunks explicitly bucketed for hash stability

But Check Your Hydration First…

If you take one thing from this, make it the third line of that checklist.

Every other item is plumbing and worth doing, none of it hard. But React.lazy suspending on an already-resolved promise is the silent, error-free, entirely invisible reason that most hand-rolled SPA prerendering delivers absolutely nothing. You’ll do all the work, ship the snapshots, look at the HTML in the built output, and conclude it’s working. Your Lighthouse score will tell you otherwise and you won’t know why.

So before you optimise anything else, go and check that React is actually keeping the markup you gave it. Everything downstream is worthless until it is.