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

  1. 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'];
  2. 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();
  3. 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]);
  4. 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.

  5. Invalidate on deploy and on data change so snapshots never outlive the live content β€” tie regeneration into your CI pipeline.

Build-time prerender pipeline for a React SPA Each route is rendered in headless Chromium, waits for a ready flag, and its captured HTML is written per route as the first response, then hydrated for users. From route list to populated first-response HTML routes.js route list Headless Chromium render route wait for ready prerender-ready flag capture DOM snapshot HTML /route/ index.html first response Crawler / social scraper reads populated HTML + metadata User same HTML, React bundle hydrates over it
The same captured HTML is the first response for crawlers and scrapers, while users hydrate it back into the live React app β€” no user-agent branch required.

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.

The whole point of the setup is what the first response contains. Compare the raw first-response HTML before and after prerendering: an empty root element versus a document a crawler can actually read.

First-response HTML before and after prerendering Without prerendering the first response is an empty root div with no title or metadata; with prerendering it contains the resolved title, canonical, Open Graph tags and heading. What a crawler receives in the first response Without prerender head: no title, no canonical no Open Graph tags div id=root (empty) no heading, no copy With prerender head: title + canonical set Open Graph tags present h1 + full copy rendered indexable on first wave
Prerendering moves the heading, copy and metadata into the first response, so a crawler that never runs your JavaScript still sees a complete page.

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();

The waitForSelector call in that script is the difference between a complete snapshot and a truncated one. On a fixed timer the capture can fire before async data resolves; waiting for the ready flag moves the capture past the load-complete point, as the two tracks show.

Fixed-timeout capture versus wait-for-ready capture on the load timeline A fixed 2 second timeout captures the DOM before async data resolves and produces a half-rendered snapshot, while waiting for the ready flag captures after data resolves and produces a complete snapshot. Capture after data resolves, not on a fixed timer data resolves Fixed 2 s timeout goto capture @ 2 s half-rendered Wait for ready flag goto ready flag set capture Β· complete load time
A fixed timer can snapshot before the data arrives; gating on the ready flag guarantees the capture happens after the content and metadata are in the DOM.

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.

← Back to Dynamic Rendering & Bot Prerendering