CSR → SSG: 73 → 81 with a tiny prerender pipeline
Vite + React Lighthouse win: CSR → SSG (73 → 81) with a tiny prerender pipeline
I took a Vite + React + React Router SPA from 73 to 81 on Lighthouse mobile with one architectural change:
Stop shipping an empty HTML shell and hoping JavaScript boots fast enough. Ship real HTML.
This isn't a micro-optimization story. It's a "change what ships at navigation time" story.
Results
Production build (pnpm build + pnpm preview), Lighthouse mobile:
- Performance: 73 → 81
- FCP: 3.53s → 2.92s (~600ms)
- LCP: 3.53s → 2.92s (~600ms)
- TBT: 139ms → 0ms
- CLS: 0.186 → 0.186 (unchanged — fonts/layout still shift)
Tip: Run Lighthouse against pnpm preview, not pnpm dev. Dev-server overhead distorts the score.
How I knew this was the fix
When Lighthouse scores poorly on a content-first site, I run this checklist:
- Is the HTML meaningful without JavaScript? No → blank screens on slow devices.
- Is the LCP element only created after hydration? Yes → LCP drifts late.
- Are there long tasks before first paint? Yes → main thread blocked by JS parse/eval.
- Is there a render waterfall? Fonts → CSS → JS → hydration → content. The page only becomes real at the end.
My baseline failed #1 and #2: empty #root, everything created by JS. FCP/LCP both sat at 3.53s. TBT was 139ms from hydration work.
The fix wasn't optimizing React. It was changing what the browser receives before React boots: pre-rendered HTML at build time.
Why CSR scores poorly
A standard Vite SPA ships one index.html with an empty <div id="root"></div>. On mobile throttling:
- HTML arrives fast — but it's empty.
- JS downloads, parses, executes.
- React mounts, router matches, content renders.
Lighthouse penalizes the gap between steps 1 and 3. The browser can't paint anything meaningful until JS finishes.
SSG, not SSR
Options: migrate to Next.js/Remix, adopt Vike, or build a minimal SSG pipeline.
I built it myself. The site is mostly static (known routes, blog posts as local modules). I don't need a runtime Node server. I wanted minimal diff and full control. The "SSR" bundle only runs at build time to produce static HTML.
The mechanism: <!--ssr-outlet-->
Add a placeholder to index.html that gets replaced during prerender:
<!-- Before -->
<div id="root"></div>
<!-- After -->
<div id="root"><!--ssr-outlet--></div>
The browser now receives HTML where #root already contains the page.
Build pipeline
From standard Vite build:
"build": "vite build"
To three-stage:
"build": "vite build && vite build --ssr entry-server.tsx --outDir dist/server && pnpm exec tsx scripts/prerender.ts"
vite build→ client assets + HTML template with hashed filenames.vite build --ssr→ Node-loadable renderer bundle.scripts/prerender.ts→ route list → HTML files indist/.
No runtime server. Output is static files.
Client entry: hydrate, don't render
Once you ship HTML, the client must hydrate, not render from scratch:
// Dev: placeholder root → render. Prod: prerendered HTML → hydrate.
if (import.meta.env.DEV) {
createRoot(rootElement).render(app);
} else {
hydrateRoot(rootElement, app);
}
In dev, the template contains <!--ssr-outlet--> — hydrating that would mismatch. In prod, prerender wrote real HTML into #root. This one detail prevents a whole class of hydration bugs.
Routing: kill the redirect, extract the tree
A <Navigate to="/home" replace /> at / produces "Redirecting…" HTML during prerender. Simplest fix:
<Route path="/" element={<Home />} />
<Route path="/home" element={<Home />} />
Extracted AppRoutes as a first-class export so both the browser (BrowserRouter) and SSR (MemoryRouter) can consume the same tree.
entry-server.tsx: renderer bundle
Needs to take a URL path and return an HTML string. Simple path switch — no React Router matching on the server:
if (path === '/' || path === '/home') page = <Home />;
else if (path === '/experience') page = <Experience />;
else if (path === '/projects') page = <Projects />;
else if (path === '/blog') page = <Writing />;
else if (pathSegments[0] === 'blog' && pathSegments[1]) {
const post = POSTS.find((p) => p.id === pathSegments[1]);
page = post ? <Post serverPost={post} /> : <NotFound />;
}
else if (path === '/contact') page = <Contact />;
else page = <NotFound />;
One critical detail: The Layout component calls useLocation(). Without router context, renderToString throws. Fix: wrap in MemoryRouter:
renderToString(
<MemoryRouter initialEntries={[path]}>
{page}
</MemoryRouter>
);
SSR safety fix #1: window / localStorage
Reading localStorage in a state initializer crashes in Node — no window. Guard it:
if (typeof window === 'undefined') return defaultValue;
return localStorage.getItem('some-key') ?? defaultValue;
One line. Difference between "SSG works" and "build fails."
SSR safety fix #2: dynamic routes need data passed in
useParams() doesn't exist during prerender — there's no router matching. Pass the post as a prop:
interface PostProps { serverPost?: PostMetadata & { content?: string }; }
const post = serverPost ?? POSTS.find(p => p.id === id);
Rule: anything the browser gets from the router or request must be computable at build time.
scripts/prerender.ts
Read client template → import SSR bundle → for each route: render HTML, inject via <!--ssr-outlet-->, write to dist/<route>/index.html. Blog posts are local modules, so dynamic routes are just POSTS.map(p => /blog/${p.id}).
Deployment
The old Vercel SPA rewrite ("/(.*)" → "/index.html") defeats SSG — it ignores the per-route HTML files. Remove it. With outputDirectory: "dist", Vercel serves the correct static file.
Why it worked
SSG puts content in the HTML. The browser paints immediately. LCP happens on first paint because the LCP element already exists. JS still boots and hydrates, but it happens after paint, not before. That's how a portfolio goes 73 → 81 without touching React perf.
What it didn't fix
CLS stayed at 0.186 — fonts and images still shifting. SSG makes first paint meaningful; it doesn't stabilize layout. Next targets: self-host fonts, reserve image dimensions, font-display: swap, code-split routes, profile hydration.