
Why We Build Prerendering Into a React SPA in 2026
We sell agent-readiness consulting. Until this morning, our own pricing page looked like this when an AI crawler fetched it:
<!doctype html>
<html lang="en">
<head><title>Syndicate Links</title></head>
<body>
<div id="root"></div>
<script type="module" src="/assets/index-abc123.js"></script>
</body>
</html>
That is what ChatGPT, Perplexity, Grok and Claude's web tool see when they hit syndicatelinks.co/pricing. An empty div. The actual pricing — tiers, prices, features, the whole reason the page exists — only appears after the React bundle downloads, parses, hydrates and runs. None of the major LLM crawlers do any of that. They fetch() the URL, read the bytes, and move on.
We are an AEO consulting company whose own pricing page was invisible to AI agents. That is humiliating in a useful way: it forces you to fix it.
Here is what we ruled out:
Server-side rendering. We run a Vite + React Router SPA on Cloudflare Pages. SSR means a Node runtime, a different deploy target, refactoring every component that touches window, and the kind of hydration mismatch debugging that eats a week. Every page on this site is publicly cacheable and rarely changes. SSR would buy us nothing we cannot get from a build-time snapshot.
Migrate to Next.js. Same problem with more steps. Two-week minimum, every Router-specific assumption rewritten, file-based routing forced on us, plus a new build pipeline and deploy target. We considered this for ten minutes and moved on.
react-snap. The classic answer. It is unmaintained — last release 2019, open issues against modern React, Puppeteer pinned to a version that no longer installs cleanly on current Node. It would have been a 30-minute install followed by a day of fighting it.
What we shipped instead is 200 lines of Node in landing/scripts/prerender.js. The shape:
// 1. Boot vite preview against ./dist on a random localhost port.
const previewServer = await preview({ root: projectRoot, preview: { port: 0 } })
// 2. Launch Puppeteer.
const browser = await puppeteer.launch({ headless: true })
// 3. For each route, navigate, wait for #root to actually have content,
// then dump the fully-hydrated HTML.
const html = await page.evaluate(
() => '<!doctype html>\n' + document.documentElement.outerHTML,
)
// 4. Write the snapshot to dist/<route>/index.html so Cloudflare Pages
// serves it as a static file.
The "wait for #root to have content" check is the only non-obvious part:
await page.waitForFunction(
() => {
const r = document.getElementById('root')
return r && r.innerHTML && r.innerHTML.trim().length > 50
},
{ timeout: 15_000 },
)
networkidle0 alone is not enough — Vite preview keeps a websocket open for HMR, so you have to wait on the actual DOM mutation. Once the React tree is in place we serialize document.documentElement which captures the current <title>, <meta name="description">, and any JSON-LD blocks the components inserted via effects.
The output goes to dist/<route>/index.html. Cloudflare Pages serves whichever exists first — our snapshot for crawlers, then the existing JS bundle hydrates on top of it for human visitors. The pricing toggle, the calculators, everything still works. We did not lose a single line of interactivity.
Routes are enumerated three ways: a hard-coded list of marketing pages in the script, the four blog slugs, and doc slugs parsed out of dist/sitemap.xml so we cannot drift. Any new doc page that lands in the sitemap gets prerendered automatically on the next build.
Total snapshot time on a clean build: roughly 60 seconds for ~50 routes on a single Puppeteer instance. We could parallelize but it is not the bottleneck.
The whole thing is one script, one dev dependency (Puppeteer), one line in package.json's build step. No SSR, no Next.js migration, no abandoned library. Our pricing page is now real HTML.
If you sell AEO and your own site is not AEO-ready, fix it today.