A React app rendered to static HTML at build time

Crawlers that never run JavaScript, and phones on slow networks, get the content in the first byte; the browser then hydrates the HTML instead of rendering from scratch.

Status
Accepted 1 October 2026, revised 2 October 2026
The record
0001-react-spa-with-prerendered-entries.md

Context

The site started as five static blocks, where plain HTML would have been enough. It now has an interactive lab (canvas simulations, live charts, server-sent events) and will get an owner-only admin panel. Links to individual experiments must unfurl properly when a recruiter pastes them into Slack or LinkedIn.

The first version shipped an empty <div id="root"> and rendered everything in the browser. That is invisible to most AI crawlers: GPTBot, ClaudeBot and PerplexityBot fetch the raw HTML and do not run JavaScript, and recruiters increasingly research candidates through exactly those tools. It also left phones staring at a blank page until the bundle had downloaded and run.

Decision

React 19 + Vite + TypeScript with client-side routing, rendered to static HTML at build time: vite build --ssr src/entry-server.tsx produces a renderer that web/scripts/prerender.mjs runs for every route, writing one HTML file per route with its own title, description, canonical URL, Open Graph tags and schema.org data (ProfilePage + Person on the home page). The browser hydrates that HTML instead of rendering from scratch. Each experiment is a lazily loaded chunk that starts only after hydration; its written notes are plain components imported with the page, so every prerendered lab page carries its framing and the full explanation while the canvases and workers stay in the browser.

Consequences

  • No server to run for HTML: the bucket serves files, and the first byte already carries the content.
  • Anything browser-only (canvas, workers, scroll restoration, theme-dependent rendering) must stay out of the server render; useHydrated() gates it without a hydration mismatch.
  • The six experiments' notes ship in the main bundle (about 7 KB gzipped) so that they can be prerendered; the lab placeholder reserves most of a screen, so the notes move below the fold when an experiment loads and never count as a layout shift.
  • The prerendered HTML must satisfy the strict Content-Security-Policy: no inline styles or scripts, so React Router's hydration data script is turned off (there are no loaders to hydrate).