Set Up Prerendering for a React SPA
A create-react-app or Vite React SPA serves <div id="root"></div> as its first response, so crawlers and social scrapers that do not run your JavaScript see nothing β no heading, no description, no Open Graph tags. Prerendering fixes this by executing each public route in a headless browser at build or request time and serving the captured HTML, putting content in the first response without migrating to server-side rendering. It is the concrete implementation of the dynamic rendering and bot prerendering approach.
Step-by-step fix
-
Enumerate the routes to prerender. Read them from your sitemap or router so coverage matches what you submit to search engines.
// routes.js β single source of truth, also used to build the sitemap export const ROUTES = ['/', '/pricing', '/blog', '/products/widget-x']; -
Render each route in headless Chromium and wait for completion. Snapshot only after the app signals it is done, never on a fixed timer.
// β Fixed wait: captures a half-rendered DOM if data is slow await page.goto(url); await sleep(2000); const html = await page.content(); // β Wait for an explicit render-complete signal the app sets await page.goto(url, { waitUntil: 'networkidle0' }); await page.waitForSelector('[data-prerender-ready]'); const html = await page.content(); -
Set the readiness flag once content and metadata are in the DOM. This guarantees the snapshot includes the final title and structured data.
// In the app: mark ready after data + head are applied useEffect(() => { document.body.setAttribute('data-prerender-ready', 'true'); }, [data]); -
Write per-route static HTML and serve it as the first response. A build-time prerenderer emits
dist/products/widget-x/index.html; an edge variant caches the snapshot and serves it on request. -
Invalidate on deploy and on data change so snapshots never outlive the live content β tie regeneration into your CI pipeline.
Validation
curl -sL <url> | grep '<title>'returns the routeβs real title, not the shell default.- Rich Results / Open Graph debuggers show the correct image, title, and description.
- GSC URL Inspection β View Crawled Page matches the live app (parity check, no cloaking).
- Hydration: load the route as a user, confirm no hydration mismatch warning in the console.
Configuration reference
// prerender.mjs β build-time snapshot generator
import puppeteer from 'puppeteer';
import { writeFile, mkdir } from 'node:fs/promises';
import { ROUTES } from './routes.js';
const browser = await puppeteer.launch();
const page = await browser.newPage();
for (const route of ROUTES) {
await page.goto(`http://localhost:4173${route}`, { waitUntil: 'networkidle0' });
await page.waitForSelector('[data-prerender-ready]'); // SEO: snapshot only when ready
const html = await page.content();
const dir = `dist${route}`.replace(/\/$/, '');
await mkdir(dir, { recursive: true });
await writeFile(`${dir}/index.html`, html); // populated HTML = first-wave indexable
}
await browser.close();
Frequently Asked Questions
Do I serve prerendered HTML to everyone or only bots? Serving the same prerendered HTML to everyone is safer than user-agent branching because it eliminates cloaking risk and the chance of an unmatched crawler receiving an empty shell. Use bot-only delivery only when a static first paint is unacceptable for users.
Will prerendering break client-side interactivity? No. The prerendered HTML is the same markup the app would produce, and the React bundle still loads and hydrates over it for users. Make sure the hydrated render matches the snapshot to avoid a hydration mismatch.
Related
- Dynamic rendering and bot prerendering β the strategy and its cloaking guardrails.
- Is dynamic rendering still recommended by Google? β whether to invest here or move to SSR.
- Why Google Indexes Blank Pages for React Apps β the failure prerendering prevents.
β Back to Dynamic Rendering & Bot Prerendering