SSG MIGRATION — PART 1
FRONTEND / ARCHITECTURE

Migrating to Static Prerendering: Where the Bugs Actually Were

I thought this was a build-tool swap. It was actually a routing problem wearing a build-tool costume — part 1 of 2.

Author
Neel Ratn
Published
Aug 27, 2026
Read Time
11 min read
Category
FRONTEND / ARCHITECTURE
Views

I moved both of my live apps — this blog and my portfolio — from a client-rendered SPA to build-time static prerendering today. Going in, I expected the hard part to be picking a tool and wiring up the build. It wasn't. Every real bug I hit came from the same root cause, and it wasn't the build tool at all: it was something the server assumed about a page that turned out not to match what was actually true once a real browser got involved.

The Problem That Started It

Both apps were plain Vite + React SPAs — index.html with an empty <div id="root">, everything rendered client-side after JS loaded. I fetched the live pages the way a bot would, without executing JS: title tag present, body empty. Every route — home, about, individual project pages, individual posts — served the exact same shell. One shared title for the whole site, no per-page description, no OG tags, nothing for a crawler or a link preview to grab onto.

Static prerendering fixes that by definition: render each route to real HTML at build time, ship that instead of an empty shell. The mechanics of how turned out to be where all the trouble lived.

Why Not Just Move to Next.js

This is the question I get asked first, so it's worth answering directly: no Next.js migration, not even considered seriously. Not because there's anything wrong with it — because it solves a different problem than the one I actually had.

Both apps are small, single-purpose Vite SPAs that already worked. Nothing here changes per request — new content shows up when I write a new post or ship a new project, which is a build event, not something a visitor's request needs to trigger. Next.js's own defaults lean toward a server that runs per request; standing up and paying for that runtime to serve content that's identical for every visitor between builds would be solving a problem I don't have.

There's also the shape of the deployment itself. Portfolio and this blog are two independently deployable apps behind a shared gateway, on purpose — each can ship on its own schedule without touching the other. Moving either one to Next.js doesn't simplify that; it's still two deploys, just now on a framework whose own routing and data-fetching conventions I'd have to re-learn and re-fit the existing MDX pipeline, dev middleware, and component structure around. That's a rewrite wearing a migration's clothes.

The actual problem — empty body, one shared title — has a narrow, purpose-built fix: render what already exists to real HTML at build time. Reaching for a full framework swap to solve a rendering-timing problem adds far more surface area than it removes. Everything in this article is evidence for that, honestly — a narrow fix still surfaced three separate hydration bugs. A framework swap would have opened a much bigger one.

Picking a Tool, and Immediately Un-Picking Half of It

The initial plan was vite-react-ssg for prerendering and @unhead/react for per-page head tags. Before installing either, I actually read the source instead of trusting the README summary, and two assumptions fell apart immediately:

  • vite-react-ssg's peer dependency is react-router-dom@^6.14.1 — not v7, which both apps had installed. Downgrading was safe (both apps only used the plain declarative router API — BrowserRouter, Routes, Route, Link — nothing v7-specific), but it wasn't the drop-in I'd assumed going in.
  • @unhead/react would have been redundant. vite-react-ssg already ships its own <Head> component, built on react-helmet-async, already wired into its prerender pipeline. Adding a second head-management library on top would've meant two systems fighting over the same <head>.

Neither of these broke anything later — they just meant the plan I started with wasn't the plan I ended up running. Worth checking the actual source before committing to a dependency pairing, not just the pitch.

Bug One: The Prefix That Wasn't Really a Prefix

Here's where it got interesting. Both apps live under a path prefix — /neelratn/* for the portfolio, /articles/* for this blog — behind a gateway that reverse-proxies to each app's own Vercel deployment. I reasoned that if the build output needed to eventually be served at /neelratn/about, the cleanest thing would be to make the build output land at dist/neelratn/about/index.html — nest the output directory under the same prefix, so no extra rewrite step was needed to bridge the two.

That reasoning felt clean. It was wrong, and it took the site down.

outputDirectory in Vercel doesn't mean "here's a folder inside my real output, please prefix requests to reach it." It means "the contents of this folder are the site's root." Point it at dist/neelratn, and a file at dist/neelratn/about/index.html gets served at /about — not /neelratn/about. The prefix I was trying to preserve got silently absorbed into the output-directory path instead. Every real request to /neelratn/* hit a 404, because nothing existed at that path anymore — the site's actual root had quietly moved.

The fix was smaller than the mistake: don't touch outputDirectory or nest outDir at all. Leave the build output flat and unprefixed, exactly like the pre-SSG build did, and let a single rewrite rule in vercel.json bridge the prefix to the real files:

{ "source": "/articles/:path*", "destination": "/:path*" }

Vercel checks the filesystem for a matching static file before it ever consults rewrite rules, so this works cleanly — the real per-route HTML files that prerendering produces get found directly, no nesting required. I'd solved a problem that didn't need solving, and in solving it, broken the thing that already worked.

Bug Two: The Race Nobody Could See on Localhost

The second bug was subtler, and it came from a completely different part of the app: route-level code-splitting. Both apps lazy-loaded their main page components —

{ index: true, lazy: () => import('./pages/Home') }

— which is a completely reasonable thing to do in a normal SPA. Under SSG, it isn't. The server renders Home synchronously during the build — Node has the whole module graph available, there's no "loading" state to speak of. But the client still has to fetch that same module as a separate JS chunk before it can hydrate that part of the page. That gap — server already rendered it, client still waiting on a network request — is exactly where a Suspense boundary earns its keep in a normal app. Under hydration, it's where things went quiet.

Timeline showing the server rendering a component synchronously during the build, while the client has to fetch a separate JS chunk before it can hydrate the same subtree — a gap where effects tied to refs can be set up too late to matter
The server already rendered it. The client is still waiting on the network. Anything that depends on 'hydration has happened' lives in that gap.

What actually broke: a scroll-reveal animation on the article grid, driven by an IntersectionObserver set up in a useEffect. On localhost, the JS chunk loads in under a millisecond — the gap in the diagram above is too small to ever matter, so everything looked fine in every test I ran locally. On real production network latency, that gap is real, and the effect that was supposed to set up the observer either ran too late or against a ref that hadn't settled yet. Cards sat at opacity: 0 forever. Nothing in the console said why — there was nothing to catch, because nothing threw. It just never got wired up.

I found it by instrumenting IntersectionObserver.prototype.observe and counting calls against the live site: zero. Not flaky, not intermittent — deterministically zero, every single time I checked, because localhost had never actually tested the failure condition at all. The fix was to stop lazy-loading those routes — plain static imports instead, accepting a slightly bigger initial bundle in exchange for removing the entire class of bug, rather than trying to patch the timing.

The Same Bug, Wearing a Different Hat

I thought that was the end of it. It wasn't — the identical failure mode showed up again, in a part of the app that had nothing to do with routing at all.

Video thumbnails on this site load from YouTube's CDN, with a fallback chain if the primary image fails: try maxresdefault.jpg, fall back to hqdefault.jpg if that one 404s. I wired that fallback through React's onError/onLoad handlers on an <img> tag. It worked in every test — until a real user reported thumbnails loading "sometimes, not always." Same shape of bug: the <img> is server-rendered with its real src already in the HTML, so the browser can start fetching it — and fail, if the URL is bad — before hydration ever attaches those handlers. A fast failure fires into an event listener that doesn't exist yet.

Once I recognized the pattern, the fix was the same shape too: don't rely solely on events that might fire before anything is listening. Check the image's actual state — img.complete, img.naturalWidth — once the component has mounted, catching whatever already happened before hydration existed to see it:

useEffect(() => {
  const img = imgRef.current;
  if (img?.complete) evaluate(img);
}, [videoId]);

Two unrelated features, same root cause, same category of fix. That's usually a sign the bug isn't really about either feature — it's about a structural assumption the whole architecture is making, and it'll keep resurfacing wherever that assumption gets relied on again.

Bug Three: A URL That Looked Like Another URL

The last one was the pettiest and the easiest to miss: /articles (no trailing slash) reliably broke, /articles/ (with it) reliably worked. No caching involved, no device-specific quirk — genuinely, deterministically different behavior for two URLs a human would consider identical.

The prerendered page's hydration data embeds an assumption about its own canonical URL — computed from the router's basename plus the route path, which comes out to /articles/, trailing slash included. A real request landing on the bare version doesn't match that assumption, and hydration doesn't reconcile cleanly against a mismatch. The fix was to stop treating that mismatch as something hydration should have to handle at all: redirect /articles to /articles/ at the routing layer, before any HTML is even served, so the browser's actual URL always matches what the build assumed.

Where This Breaks Down

None of this is free. Every route now needs real, static-at-build-time content — anything genuinely dynamic per-request (not per-build) doesn't fit this model without a different tool entirely. And the debugging cost is real: three of four bugs here were invisible on localhost and only appeared under conditions I couldn't fully reproduce without either real network latency or a production deploy. That's a worse debugging loop than a bug that just fails the same way every time, and it's worth knowing that going in, not discovering it after the second outage.

Next Steps

A few concrete things I haven't done yet, in rough order of how much I actually care about them:

  • Higher-fidelity local verification. Every local test I ran passed on a Docker/nginx setup whose routing semantics turned out to differ from Vercel's in exactly the place that mattered — nginx's trailing-slash redirect quirk isn't Vercel's trailing-slash bug, they just look similar. I ended up hand-simulating Vercel's actual filesystem-then-rewrite precedence in a throwaway Node script mid-incident, which worked, but shouldn't have to be improvised under pressure. Running vercel build/vercel dev locally instead of a hand-rolled approximation is the honest fix.
  • Critical CSS inlining. vite-react-ssg has built-in support for this (beastiesOptions in its config) that I haven't turned on. Given every page already renders fully at build time, inlining the CSS that page actually needs is a natural next step, not a new architecture.
  • Its own data-loader pattern, instead of a side-channel script. Right now, the list of posts to prerender comes from a standalone script (generate-posts-manifest.mjs) that runs before the build and writes a JSON file the route config imports. vite-react-ssg supports React Router's own loader convention for exactly this kind of build-time data-fetching. Worth moving to, mostly for having one fewer moving part rather than because the script is actually broken.

Key Takeaways

Every bug in this migration traced back to the same shape: something true on the server — a file's path, a component's availability, an event that already fired — didn't match what was actually true by the time a real browser got there. Prerendering doesn't remove that gap. It just moves it from "the whole page is empty until JS runs" to a handful of much narrower places where server and client have to agree, and where they don't, they fail quietly instead of loudly.

Part 2 covers why any of this was worth doing in the first place — the SEO and GEO reasoning most of this migration was actually in service of.

Comments

Loading comments…