How it works
The build pipeline.
Prestruct adds two Node scripts to your existing Vite build. They run after vite build, take about 2 seconds, and leave you with a dist/ that search engines can crawl.
Step by step
vite build
Your standard Vite production build. Produces dist/ with content-hashed JS and CSS bundles. dist/index.html is a shell with empty meta placeholders at this point.
node scripts/inject-brand.js
Reads your ssr.config.js and writes global title, meta description, Open Graph tags, Twitter Card tags, and JSON-LD schema into dist/index.html. This becomes the shell that step 3 stamps per-route meta on top of.
node scripts/prerender.js
Spins up a Vite dev server, loads AppLayout via ssrLoadModule, wraps it in StaticRouter for each route, renders to string, stamps per-route title, description, canonical, and og:url, then writes dist/route/index.html. Also generates 404.html with a real HTTP 404 status and a fresh sitemap.xml dated today.
Cloudflare Pages deploy
CF uploads only changed files. Each route's HTML is served with HTTP 200 and Cache-Control: no-cache, so users always get the latest deploy. Hashed JS/CSS assets get max-age=31536000, immutable. 404.html is served automatically with HTTP 404 for unmatched paths.
Caching strategy
Cache-Control: no-cache
Every HTML file revalidates on each request. Users always get the latest deploy. The revalidation is fast because the content lives on Cloudflare's CDN edge.
Immutable, 1 year
Vite content-hashes every bundle filename. The hash changes when content changes, so index-DnaYLP7Z.js will never change. Safe to cache forever.
Short TTL
sitemap.xml and robots.txt cache for 24 hours. Long enough to avoid hammering the origin, short enough to pick up route changes quickly after a deploy.
Key technical decisions
ssrLoadModule instead of vite build --ssrA compiled SSR bundle creates a separate module instance from the client bundle. StaticRouter and Routes end up with different copies of react-router-dom. Location context never propagates, and every route silently renders as the homepage. ssrLoadModule uses Vite's unified registry: one instance, one context, correct output.
AppLayout must never import BrowserRouterssrLoadModule executes all imports at load time. BrowserRouter initializes immediately against window.location, which defaults to / in Node. This fires before StaticRouter can set the correct location. Keep BrowserRouter in App.jsx only.
hydrateRoot instead of createRootcreateRoot replaces the entire DOM on mount, causing a repaint even when the SSR HTML matches perfectly. Users see a flash (FOUC). hydrateRoot attaches React to the existing SSR DOM without touching it. The page the crawler indexed is identical to what the browser paints.
const root = document.getElementById('root')
if (root && root.dataset.serverRendered) {
ReactDOM.hydrateRoot(root, <React.StrictMode><App /></React.StrictMode>)
} else if (root) {
ReactDOM.createRoot(root).render(<React.StrictMode><App /></React.StrictMode>)
}