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.

01

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.

02

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.

03

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.

04

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.

HTML pages

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.

JS + CSS assets

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.

404 + sitemap

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.

Why ssrLoadModule instead of vite build --ssr

A 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.

Why AppLayout must never import BrowserRouter

ssrLoadModule 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.

Why hydrateRoot instead of createRoot

createRoot 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.

main.jsx
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>)
}