SSR Prerendering a React SPA for Static Hosting

April 5, 2026

power-explained is a React/Vite SPA with 83 routes deployed to Cloudflare Pages. Single-page apps are invisible to search engines by default — the crawler fetches the HTML shell, sees no content, and moves on. The fix is server-side rendering: generate a static HTML file per route at build time, with the full content and all the meta tags baked in.

The core problem is that React's lazy() and renderToString() don't compose. lazy() returns a promise; renderToString() is synchronous. If your route components are lazy-loaded — which is the standard Vite SPA pattern for code-splitting — your SSR entry will silently render empty shells for every page. You won't get an error. You'll just get bad output.

The solution is a separate server entry file that imports every route component eagerly. Not the same entry as the client bundle — a dedicated entry-server.jsx that uses StaticRouter instead of BrowserRouter and has explicit eager imports for all 83 pages. The prerender script runs the Vite SSR build, iterates every route, calls renderToString() with the right path, injects 13 head tags (title, description, canonical, all the OG and Twitter variants), and writes dist/slug.html and dist/slug/index.html for each. Cloudflare Pages serves whichever it finds.

The build sequence that came out of this: vite build → prerender → Pagefind index → sitemap generation. Pagefind runs against the prerendered HTML, which means the search index sees real content rather than a JavaScript shell. The sitemap is generated from the same pages manifest that drives the routes, so it can't drift out of sync.

The failure mode worth documenting: if you forget the eager imports and run the build, everything appears to succeed. The dist files exist, the paths resolve, the site loads. The bug only shows up when you inspect the prerendered HTML and find that the article body is absent. Test by grepping for known article text in the dist output before deploying.

← All entries