Vite + React Lighthouse win: CSR → SSG (73 → 81) with a tiny prerender pipeline
This post is a deep dive into a very “portfolio-site-ish” performance problem:
- Production Lighthouse (mobile) performance improved 73 → 81 after prerendering
- The site felt “choppy” on mobile mainly during startup (blank → pop-in / late first paint), not necessarily scroll-jank after load
- The app is Vite + React + React Router, originally CSR-only
I got the Lighthouse score to 81 by changing one architectural thing:
Stop shipping an empty HTML shell and hoping JavaScript boots fast enough. Ship real HTML.
This wasn’t a “micro-optimize React renders” story. It was an “improve what ships at navigation time” story.
Results (measured)
Measured on a production build (pnpm build + pnpm preview) using Lighthouse mobile.
- Performance: 73 → 81
- FCP: 3.53s → 2.92s (~600ms faster)
- LCP: 3.53s → 2.92s (~600ms faster)
- TBT: 139ms → 0ms
- CLS: 0.186 → 0.186 (unchanged)
Note: in this case FCP and LCP line up here because the LCP element is present immediately and gets painted on the first render.
Takeaway: SSG improved startup paint and removed blocking time, but CLS still needs targeted work (fonts/layout).
Tip: Don’t judge this in pnpm dev. Run Lighthouse against pnpm preview, otherwise dev-server overhead will distort the score.
The thought process: how I decided this was the right fix
When Lighthouse is low on a content-first site (portfolio/blog), I usually run this mental checklist:
- Is the HTML meaningful without JavaScript? If the answer is “no”, you’ll see blank screens on slow devices.
- Is the LCP element only created after hydration? If yes, LCP drifts late and the score tanks.
- Are there long tasks before the first meaningful paint? JS parse/eval and hydration can block the main thread.
- Is there a “render waterfall”? (fonts + CSS + JS/hydration + markdown rendering) where the page only becomes “real” at the end.
In my baseline, #1 and #2 were true: I shipped an empty #root and relied on JS to create the content.
The numbers confirmed it: FCP/LCP improved ~600ms and TBT dropped to 0ms once the LCP element existed in HTML instead of waiting for React to create it.
So instead of optimizing the app after it loads, I changed what happens before it loads: I pre-rendered HTML at build time.
Baseline architecture: CSR SPA (why it often scores poorly)
The starting setup was a normal Vite SPA:
- One
index.htmlfor every route #rootstarts empty- React mounts, router matches, then UI renders
That’s the simplest setup, but on mobile throttling it creates the exact “blank → pop-in” experience Lighthouse penalizes.
Why Lighthouse was low: content gated behind JS
Lighthouse doesn’t care that your UI feels fine after boot. It cares that:
- the browser can paint meaningful content early, and
- the main thread isn’t blocked so long that it delays the first real render.
In CSR:
- HTML arrives quickly, but contains no real content
- JS must download/parse/execute
- React must render and paint the LCP element
That pushes the first meaningful paint later → low score.
Choosing the solution: SSG (build-time prerender), not runtime SSR
I could have:
- Migrated to a framework (Next.js/Remix)
- Adopted a Vite SSR framework (e.g. Vike)
- Or built a small SSG pipeline myself
I chose the last option because:
- This site is mostly static (routes known; blog posts are local modules)
- I don’t need a runtime Node server
- I wanted a minimal diff and full control
I still build a Vite “SSR” bundle, but I only use it at build time to generate static HTML (SSG). No Node server at runtime.
The core mechanism: <!--ssr-outlet--> in index.html
This is the smallest “enable SSG” change: add a placeholder in the HTML template that I can replace during prerender.
Before:
<body class="grain">
<div id="root"></div>
<script type="module" src="/index.tsx"></script>
</body>
After:
<body class="grain">
<div id="root"><!--ssr-outlet--></div>
<script type="module" src="/entry-client.tsx"></script>
</body>
Now the browser can receive an HTML document where #root already contains the page.
The build pipeline (exactly what I wired up)
I changed package.json from a standard Vite build…
"build": "vite build"
…to a three-stage build:
"build": "vite build && vite build --ssr entry-server.tsx --outDir dist/server && pnpm exec tsx scripts/prerender.ts"
Why this structure works:
vite buildcreates the final client assets + the HTML template with correct hashed filenames.vite build --ssrcreates a Node-loadable renderer bundle.scripts/prerender.tsturns route list → HTML files.
There’s no runtime SSR server, and the output is just static files under dist/.
Client entry: hydrate in production (and why dev must render)
Once you ship HTML, you must hydrate, not “render from scratch”.
I replaced the old index.tsx with entry-client.tsx and used this logic:
// Dev: empty/placeholder root → render. Prod: prerendered HTML → hydrate.
if (import.meta.env.DEV) {
createRoot(rootElement).render(app);
} else {
hydrateRoot(rootElement, app);
}
Thought process:
- In dev, Vite is not prerendering; the template contains
<!--ssr-outlet-->. Hydration would mismatch, so dev should render. - In prod, prerender writes real HTML into
#root, so the client hydrates.
This is one of those details that prevents hydration mismatch noise and weird UI behavior.
Note: a more robust approach is “hydrate if #root already has HTML”, which works even in non-dev preview environments.
Routing changes: remove redirect at / and extract AppRoutes
Redirect trap at / (the “stuck redirecting” issue)
I started with a redirect at the root:
<Route path="/" element={<Navigate to="/home" replace />} />
In prerender output, this frequently becomes “Redirecting…” HTML unless you implement explicit redirect handling.
The best fix for a static portfolio is also the simplest:
<Route path="/" element={<Home />} />
<Route path="/home" element={<Home />} />
Now / is the homepage, no redirects needed, and prerender produces correct HTML.
Extracting the route tree for reuse
I refactored App.tsx so the route tree is a first-class export:
export const AppRoutes: React.FC = () => (
<Routes>
<Route path="/" element={<Home />} />
<Route path="/home" element={<Home />} />
<Route path="/experience" element={<Experience />} />
<Route path="/projects" element={<Projects />} />
<Route path="/blog" element={<Writing />} />
<Route path="/blog/:id" element={<Post />} />
<Route path="/contact" element={<Contact />} />
<Route path="*" element={<NotFound />} />
</Routes>
);
This makes the app more flexible (client/router concerns stay at the edges).
entry-server.tsx: the SSR renderer bundle we run at build time
I need a function that can do:
- input: URL/path
- output: HTML string
I didn’t rely on React Router server-side matching; I used a small path switch for prerendering, and wrapped the result in a Router only to satisfy hooks like useLocation() used by Layout.
My entry-server.tsx renderer does this by switching on the URL and rendering the corresponding page:
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 />;
}
The important detail: MemoryRouter for useLocation()
Even though the renderer doesn’t use React Router to match routes, page components include a shared Layout that uses useLocation().
Without router context, prerender fails with:
useLocation() may be used only in the context of a <Router> component.
So I wrap the page in a router for SSR:
renderToString(
<MemoryRouter initialEntries={[path]}>
{page}
</MemoryRouter>
);
That makes nav highlighting and “close menu on route change” behave during prerender the same way they do in the browser.
SSR safety fix #1: localStorage (or any window) in component state
This is a classic “works in browser, crashes in Node” issue.
If you read localStorage or any window API inside a state initializer, SSR will throw (no window in Node).
Fix: guard with typeof window === 'undefined' and return a default so the server never runs the client-only code:
if (typeof window === 'undefined') return defaultValue;
return localStorage.getItem('some-key') ?? defaultValue;
This tiny guard is the difference between "SSG works" and "build fails".
SSR safety fix #2: dynamic blog routes need data passed in
Post originally found the post via useParams():
const { id } = useParams<{ id: string }>();
const post = POSTS.find(p => p.id === id);
But in prerender, we’re not doing router matching on the server, so there’s no params object.
I fixed it by allowing the server to pass in the post:
interface PostProps {
serverPost?: PostMetadata & { content?: string };
}
const post = serverPost ?? POSTS.find(p => p.id === id);
And in the server renderer:
page = post ? <Post serverPost={post} /> : <NotFound />;
General SSG rule:
Anything the browser normally gets from the router/request must be computable at build time and passed in.
scripts/prerender.ts: turning routes into files
This is the “SSG generator”:
- Read the built client template (
dist/index.html) - Import the SSR bundle (
dist/server/entry-server.js) - For every route:
- render HTML
- inject into template by replacing
<!--ssr-outlet--> - write
dist/<route>/index.html
Essential shape:
const template = readFileSync(join(distDir, 'index.html'), 'utf-8');
const { render } = await import(serverPath);
for (const path of PRERENDER_PATHS) {
const html = render(path);
const fullHtml = template.replace('<!--ssr-outlet-->', html);
writeFileSync(outPathFor(path), fullHtml);
}
Because blog posts are local modules, I can enumerate dynamic routes:
...POSTS.map((p) => `/blog/${p.id}`)
Deployment: remove SPA rewrites so real files can be served
The old Vercel config was a SPA rewrite:
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
That defeats SSG: it serves /index.html even when /blog/foo/index.html exists.
I changed it to:
{
"buildCommand": "pnpm run build",
"outputDirectory": "dist"
}
Now Vercel serves the correct static file per route.
Why Lighthouse improved (the short, honest version)
With SSG:
- The browser can paint meaningful content immediately (HTML contains the page).
- LCP happens earlier because the LCP element is already present at first paint.
- JS startup/hydration still costs CPU, but it happens after the first meaningful paint instead of before it.
That’s why a portfolio/blog can gain a meaningful bump (in my case 73 → 81) without touching “React perf” at all.
What SSG did not fix
Even after prerender, CLS stayed at 0.186. That means something is still shifting during load (often web fonts, images without dimensions, or late-injected styles).
SSG makes the first paint meaningful, but it doesn’t automatically stabilize layout or eliminate hydration cost.
What’s next if you want to push >90
Next wins I’d target:
- Fix CLS: preload critical fonts, add
font-display: swap, and ensure images/components reserve space (explicit width/height). - Route-level code splitting (especially around markdown rendering)
- Delay expensive work until after first paint (e.g., non-critical effects/animations)
- Profile hydration long tasks (Performance panel + React Profiler) and remove unnecessary rerenders
If your remaining “choppiness” is after load (scroll jank, animation jank), that’s the layer to optimize next.