One shell, every URL
The SPA model is straightforward: ship one HTML file, let the JavaScript bundle take over, mount the right component for the URL. It works beautifully for users with browsers. It works poorly for everyone else.
Search crawlers run JavaScript inconsistently — some do, some don’t, some give up after a few seconds. Link-preview bots on social networks and chat apps mostly don’t run JavaScript at all. When any of them hit a page, they read the static HTML the server returned. For a vanilla SPA, that HTML is identical on every URL: the same fallback title, the same default Open Graph image, the same empty body. Different pages, one card.
That is fine if your product lives entirely behind a login. It’s not fine for marketing pages, blog posts, docs, or anything you want people to find and share.
One HTML file per public route
The fix is to pre-render every public URL at build time. Each route gets its own dist/<route>/index.html with the real content baked in and the right metadata in <head>. At runtime there’s no Node server, no per-request render — the static files go to any CDN and serve from the edge.
The mechanics are small. There’s a single source-of-truth list pairing each URL with the React component it should render. A build-time script loops that list, asks React to render each entry to a string, and writes the result to disk under the matching path.
Worth pinning down the language here, because it’s the distinction the rest of the post hinges on: we are using server-side rendering — React’s streaming renderer, a Node-only bundle, the whole apparatus. We just run it at build time instead of per request. SSR techniques, pre-render delivery. The same primitives that a runtime SSR framework uses to render every incoming request, we use once per route, then ship the output to a CDN.
Compared to doing nothing: pages now have real titles, real descriptions, real preview cards, and the first paint shows actual content instead of an empty shell. Compared to a headless-browser screenshot pipeline: no 250 MB Chromium download in CI, no flaky timeouts, no browser-binary drift. We tried the headless-browser route first and ripped it out — the install time alone was killing the build.
React 19 hoists metadata for you
Per-page <title> and <meta> tags used to need a head-manager library — a context provider, a render-on-mount dance, a wrapper around the renderer to flush collected tags. React 19 removed the need. If a component renders <title>, <meta>, or <link> anywhere in the tree, React hoists it into <head> automatically. The same behaviour works in the streaming server renderer.
In practice this means each page imports a tiny <SEO title="..." description="..." /> component that just renders the tags inline next to the rest of the page. No provider. No context. No library wrapper. The component is shorter than its prop interface.
One catch: the metadata hoisting only happens with the streaming renderer (renderToPipeableStream or renderToReadableStream), not the legacy renderToString. If you’re upgrading from React 18, that’s the swap you need to make on the SSR side.
Two builds, one repo
The same Vite project runs two builds. The first is the ordinary client SPA build — everything you already have, plus a code-split chunk for React. The second is a Node-only SSR build pointed at a separate entry file: vite build --ssr src/entry-ssr.tsx. It produces a small bundle that exports a render(url) function and the list of paths to render.
The SSR entry imports only the public marketing route subtree. That’s deliberate. Anything browser-only — the account handling SDK, analytics, push notification setup, the in-app audio worker, anything that touches window at import time — is kept out of the SSR bundle on principle. The SSR bundle stays tight, and you sidestep the classic SSR landmine of “this hook crashes when there is no window”.
A small Node script ties the two together. It reads the client’s dist/index.html as a template, imports the SSR bundle, calls render(url) for each route, and writes dist/<route>/index.html. That script is ~100 lines of plain JavaScript — nothing exotic.
One nice detail on the SSR side: we don’t need a real router. We already know the exact URL we’re rendering, so we just mount the leaf component directly — no <Routes> / <Route> dispatch involved. The only reason a router shows up at all is that the leaf components use <Link>, which needs a router ancestor. A <MemoryRouter initialEntries={[url]}> wrap satisfies that and contributes nothing else — no history, no listeners, no navigation. The browser’s real router takes over once the SPA hydrates.
Account-walled pages still need a crawl-able card
Pages behind authentication can’t render server-side the normal way — their render path touches session state and browser APIs that don’t exist in Node. The workaround is small: ship a head-only companion next to each one that renders just the <SEO> tags. The SSR bundle imports the companion; after hydration the real component mounts over it. Crawlers get a real preview card, users get the full app. Cost is one extra component per page that matters for SEO — fine when the share preview is static, not the right tool for metadata that varies per user.
The OG card pipeline: React component, PNG out
Open Graph preview images — the cards that show up when someone drops a link into a chat — are PNGs. You need one per public page if you want each share to look distinct. Generating them by hand in a design tool, exporting per page, re-exporting whenever the title changes, doesn’t scale past about three pages.
The pipeline we landed on is a small script with four steps:
- Call the React template directly with its props (e.g.
BlogOgImage({ title: '...' })) to get its expanded element tree — a tree of<div>s and strings, no JSX compiler involved at this point. - Hand the element tree to Satori, which walks it and emits an SVG — one call, signature
satori(element, { width, height, fonts }). Satori takes React-style element objects directly — no intermediate HTML string, no DOM, no browser. The one gotcha: it only knows DOM element types (strings like'div'), so you have to call the template function rather than wrap it inReact.createElement— otherwise Satori sees an unresolved function reference and bails. - Pipe the SVG to Sharp (
sharp(svg).png().toFile(path)), which writes out a PNG. - One command, one PNG, deterministic, checked into the repo. Run per target — once per blog post, once per example page, once per generic route.
The two screenshots below are real outputs of that pipeline — same React template, different content, identical visual system.


The win for designers is the part we underrated up front. The template is a React component. It takes props. It uses CSS-style styling. It reads from the same design tokens the rest of the codebase uses. Adjusting a font weight, a corner radius, a background gradient, the icon layout — that’s a normal pull request, reviewed like any other component change. No round-trip through a design tool. No SVG hand-edit. No screenshot pipeline to babysit.
The honest caveat: Satori implements a subset of CSS. Flexbox is in, grid is out, some font features and most animations aren’t supported, and there’s no JavaScript at render time. So “React component” is more precisely “React with style props inside Satori’s supported subset.” For a card-style layout — title, description, logo, a few icons, a background image — you almost never bump into the limits.
One implementation gotcha worth flagging: Satori uses opentype.js to parse fonts, which can’t decompress WOFF2’s Brotli payload. If your fonts are WOFF2, run them through wawoff2.decompress first to get plain SFNT bytes. One line, easy to miss, painful to debug.
Strip the template’s defaults before splicing per-route tags in
The SPA’s dist/index.html ships with default <title> and Open Graph <meta> tags so that any route the prerender step doesn’t cover still shares a sensible fallback card. When the prerender script splices per-route metadata into a route’s HTML, it has to strip the defaults first.
Leaving both sets in produces duplicate tags. Some scrapers pick the first, some the last, some neither. None of them pick “the right one”, because there isn’t one any more — there are two. A small regex pass over a known list of meta keys removes the defaults before the splice. Cheap to add, expensive to forget.
Runtime SSR solves the same problem at a higher price
Runtime SSR — frameworks like Next.js or Remix, rendering each request on a live Node server — solves the same crawler problem, just one HTTP request at a time instead of once at build. We considered going that way. The reasons we didn’t are mostly about what you don’t have to deal with when the same SSR mechanics run at build time instead of per request:
- No always-on Node server. Static files serve from any CDN. No scaling rules, no cold starts, no runtime infrastructure to monitor, no per-request render cost.
- Every component stays SPA-shaped. No
typeof window === 'undefined'checks scattered around. No hydration mismatches to chase. The static markup is frozen at build time, so any runtime differences are local to client-only state, where you expect them. - Browser-only libraries keep working as-is. Auth providers, analytics, audio and ML libraries that touch
window— they all run after hydration on the client, exactly like a regular SPA. No SSR-safe shimming. - No framework-mandated file structure. Keep the router you have, the data-fetching library you have, the folder layout you have. No migration to a new routing or loader model.
- Local dev stays plain. Same
vite dev, same hot reload, same mental model. Onboarding is “it’s an SPA”, not “let me explain this framework’s server runtime”. - Auth integration stays simple. No server-side session cookie handling, no edge runtime quirks, no figuring out which permutation of redirects works in the framework’s server context.
- Deploy is static files. The output of the build is HTML, JS, and assets. Drop it on object storage, point a CDN at it, done. No Node runtime configuration, no ISR or SSG knobs.
The honest trade-off is real. This approach only works for URLs you can enumerate at build time — marketing pages, blog posts, docs, a finite catalogue of example pages. Genuinely dynamic public surfaces (user profile pages anyone can view, search result pages, large e-commerce catalogues that change hourly) need something else. For us, every public URL is enumerable and every dynamic page is account-walled, so the split landed exactly where we wanted it. If your public surface is mostly dynamic, full SSR earns its weight; if it’s mostly static, you’re paying for capability you don’t need.
Three places that drift if you let them
The route list, the sitemap, and the OG image set need to stay in sync. Add a new public page and forget one of them, and the failure is silent: the build still passes, the page still renders for a logged-in user, but the social preview breaks, or the page doesn’t show up in search, or the CDN serves the SPA shell instead of the prerendered HTML.
We bundled the chain into a single command. Adding a new public route appends to the prerender list, appends to the sitemap, and runs the OG generator for the new slug — all in one step. The point isn’t the command itself; it’s that the three things that have to move together actually move together. If you adopt this pattern, codify the chain early. Trusting yourself to remember three places later is a losing bet.
The landmines we hit
A few honest snags worth knowing before you build this:
- React 19 is required for the metadata hoisting to work in the SSR path. Legacy
renderToStringdoesn’t hoist<title>or<meta>even when they’re inside a full document tree. - The SSR bundle creeps toward browser-only deps if you import shared components carelessly. A page that renders fine in the browser can break the SSR build by transitively pulling in a hook that reads
localStorageat import time. Treat the SSR bundle as its own deployment target with its own allow-list. - Duplicate metadata tags are the silent failure mode. Strip the SPA template’s default
<title>and OG tags before splicing per-route ones in. Different scrapers pick different copies; none of them pick “the right one”. - Companion components drift. Every account-walled page’s SEO companion is a small piece of code maintained in parallel with the real component. If the page’s title or description changes, the companion needs to follow. Code review catches this; nothing automatic does.
- OG generation is a separate manual step, not a default build pass. PNG rendering is heavy and slow in CI, so it doesn’t run on every build — it runs per target when a page is added or its metadata changes. Easy to forget on a rush PR.
What this gets you
Real per-page titles in search results. Working preview cards when someone drops a link into a chat. First paint that shows actual content. No Node server at runtime, no SSR framework migration, no per-component SSR-safety audit. The maintenance surface is a route list, a sitemap, a handful of companion components, and an OG image generator — all of which fit in a single PR when a new public page lands.
For a product whose public surface is enumerable, the trade-off landed clearly on our side. The next time we add a marketing page, the cost is one command and a paragraph of copy. The crawler sees what users see. The link preview renders the page’s own card. Which is what you wanted from an SPA all along — just without the runtime overhead of a framework that ships with twenty other things attached.
