Prerendering a React SPA Into Static HTML — No Framework, No Server
How ToolRunner turns a client-side React Router app into fully prerendered static pages with renderToString, react-helmet-async, and one Node script.
by Dowon Oh
ToolRunner is a pile of browser-based developer tools — a cron generator, a JSON viewer, an HTTP header analyzer, and about forty more routes. Everything runs client-side; there is no backend, and I want to keep it that way. But a pure client-side React app has a well-known problem: the server responds to every URL with the same near-empty index.html, and everything a search engine (or a link-preview bot, or a user on a slow connection) sees depends on JavaScript executing first. The usual answer is "just use Next.js" or "just use Astro." I did neither. ToolRunner is a plain Vite + React Router SPA, and at build time a single Node script renders every route to its own static index.html and drops the output on Cloudflare Pages. This article walks through exactly how that works — the ~20-line server entry, the prerender loop, the 404.html fallback, the git-derived sitemap — and two gotchas that are documented as comments in the code because they each cost me real debugging time.
Why prerender at all
Google can render JavaScript. But its own documentation is candid about the cost: Google's JavaScript SEO basics describes rendering as a separate, queued phase after crawling — the page gets fetched, then waits until resources allow Chromium to execute its scripts, and only then does indexing see the real content. For a hobby-scale site without much crawl priority, that queue is where your pages sit. And Google is the good case; plenty of crawlers and every social link-preview fetcher do no rendering at all. If the <title>, meta description, canonical URL, and Open Graph tags only exist after React mounts, a large share of the machines reading your site never see them.
Prerendering fixes this without changing the app's runtime model. The site is still a SPA after hydration — client-side navigation, no server round-trips — but the first byte of every route is complete HTML with the right head tags baked in. The trade I made is that I own the rendering pipeline myself instead of adopting a framework. It turned out to be a small thing to own: one server entry file and one build script.
One render function: entry-server.tsx
The entire server-side surface of the app is this file:
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router';
import { HelmetProvider } from 'react-helmet-async';
import type { HelmetServerState } from 'react-helmet-async';
import AppRoutes from './AppRoutes';
export function render(url: string) {
const helmetContext: { helmet?: HelmetServerState } = {};
const html = renderToString(
<HelmetProvider context={helmetContext}>
<StaticRouter location={url}>
<AppRoutes />
</StaticRouter>
</HelmetProvider>
);
return { html, helmet: helmetContext.helmet };
}
Three pieces are doing the work. renderToString renders the React tree to an HTML string synchronously — the React docs push you toward the streaming APIs for real servers, and they are right to, but for a build script that renders fifty routes once per deploy, the blocking string API is exactly the right amount of machinery. StaticRouter is React Router's memory-only router: instead of reading window.location (which does not exist in Node), it renders whatever route you pass as location. And HelmetProvider with an explicit context object is how react-helmet-async does head extraction on the server: every <Helmet> rendered anywhere in the tree accumulates into helmetContext.helmet, and after renderToString returns, that object holds the title, meta, link, and script tags for the route as serializable state.
Vite builds this file separately from the client bundle — the build script in package.json runs vite build for the client, then vite build --ssr src/entry-server.tsx into dist/server/, then invokes the prerender script. No dev-time SSR, no request-time SSR. The "server" exists for the duration of one Node process at build time.
The prerender loop
scripts/prerender.js owns the route list and the file writing. The routes are a hardcoded array — every tool page, the static pages, plus blog routes that are auto-discovered by globbing src/content/articles/*.md, so publishing an article is just adding a markdown file. The loop itself reads the Vite-built index.html as a template and, for each route, splices in the rendered app and the extracted head tags:
for (const route of ROUTES) {
const { html: appHtml, helmet } = render(route);
let page = template.replace(
'<div id="root"></div>',
`<div id="root">${appHtml}</div>`
);
const helmetTags = [
helmet.title?.toString() ?? '',
helmet.meta?.toString() ?? '',
helmet.link?.toString() ?? '',
helmet.script?.toString() ?? '',
].filter(Boolean).join('\n ');
// Replace the static <title>toolrunner</title> placeholder
page = page.replace('<title>toolrunner</title>', helmetTags);
const dir = join(CLIENT_DIR, route.slice(1));
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'index.html'), page, 'utf-8');
}
Two details are worth calling out. First, the head injection strategy is deliberately dumb: the source index.html carries a placeholder <title>toolrunner</title>, and the script replaces that one literal string with the full block of helmet-generated tags — title, meta description, canonical link, and the JSON-LD <script> blocks. String replacement on known markers is fragile in theory, but it is trivially debuggable, and both markers (<div id="root"></div> and the placeholder title) are under my control in the template. Second, every route writes to route/index.html — dist/client/json-viewer/index.html, not json-viewer.html — because that is the layout static hosts resolve for a clean URL. The loop counts failures, and if any route throws, the script exits non-zero and the deploy fails. A route that stops rendering on the server is a build error, not a silent regression discovered in Search Console three weeks later.
The <script> tags in that helmet block are the JSON-LD structured data — SoftwareApplication for tool pages, Article plus BreadcrumbList for posts like this one. Since they come out of the same SEO component the client renders, prerendered head and hydrated head agree by construction.
The 404 fallback
Cloudflare Pages has a specific contract for missing routes: per the Cloudflare Pages serving documentation, if a request matches no asset, Pages serves the project's root 404.html with an actual HTTP 404 status. That is much better than the classic SPA fallback of rewriting everything to index.html with a 200 — soft-404s where a garbage URL returns a "successful" empty shell are something crawlers explicitly penalize.
So the prerender script generates 404.html the same way it generates everything else: it calls render('/404-not-found-page') — a path that intentionally matches no route, so React Router's * catch-all renders the NotFound page — and writes the result to dist/client/404.html. The not-found page gets real head tags like every other page. Any URL I never prerendered gets a proper 404 status plus a rendered page with navigation back into the site, and I wrote zero redirect rules to make that happen.
Gotcha one: canonicals must match Cloudflare's trailing slash
This one lives as a comment in SEO.tsx, and it earned its place. Because every route is a directory containing an index.html, Cloudflare Pages canonicalizes URLs with a trailing slash: request /json-viewer and the edge answers with a 308 Permanent Redirect to /json-viewer/. That is fine on its own. The bug is what it does to your canonical tags. My SEO component originally emitted canonicals from the route path as written — https://toolrunner.dev/json-viewer, no slash. Which means every page was telling Google "the canonical version of me is that other URL over there — the one that answers with a redirect." A canonical pointing at a 308 is an indexation coin-flip.
The fix is a one-liner that now guards every URL the component emits:
// Canonical URLs MUST have a trailing slash on non-root paths to match
// Cloudflare Pages' served URL (which 308-redirects no-slash → slash).
function normalizeCanonicalPath(path: string): string {
if (path === '/' || path === '') return '/';
return path.replace(/\/?$/, '/');
}
Same normalization applies to the URLs inside the JSON-LD breadcrumbs and the Open Graph og:url. There was also a second, sneakier version of this bug: at some point the app had a client-side redirect component handling trailing slashes — and it ran in the wrong direction, stripping the slash the edge had just added, directly contradicting the canonical. The comment in AppRoutes.tsx records its removal. The lesson generalizes: whoever serves your files decides your canonical URL shape, and everything you emit — canonicals, sitemaps, structured data, internal links — has to agree with the host, not with your router's idea of a path. It is the same class of reasoning I go through for header configuration in the HTTP security headers guide: the edge's behavior is part of your configuration whether you wrote it or not.
Gotcha two: SSR-unsafe libraries behind a lazy client shell
renderToString runs your components in Node, and some libraries simply cannot survive that. ToolRunner's PDF tools depend on pdfjs-dist and react-pdf, which reach for browser globals at import time; the cURL converter pulls in a similarly heavy parser. Import any of these at the top of a page module and the prerender of that route throws — and since import graphs are transitive, one careless import can take down the build.
The naive fix is to make the whole route React.lazy and skip prerendering it. But that surrenders exactly the thing I built this pipeline for: the page's SEO content would vanish from the static HTML. The pattern that works — documented in the routes file comment and implemented in pages like PDF Watermark — is to split each such page into a shell and a client subtree:
- The shell is the eagerly-imported route component. It renders everything that matters for SEO and first paint — the
SEOhead tags, the heading, the feature description, the FAQ content — using nothing that touches browser APIs. This is whatrenderToStringsees, so the prerendered HTML is complete. - The client subtree holds the actual tool UI and all the SSR-hostile imports, loaded via
React.lazy(() => import('./PdfWatermarkClient'))inside a<Suspense>boundary. During prerender the suspended child renders as its fallback; in the browser, hydration mounts the shell instantly and the heavy chunk streams in after.
The route table stays uniform — every route eagerly imports its shell, and the lazy boundary lives inside the page, at exactly the line where browser-only code begins. As a bonus, the multi-megabyte PDF machinery lands in its own chunk that only users of PDF tools ever download.
The sitemap: lastmod from git, not from the build clock
The last piece is scripts/generate-sitemap.js, which the prerender script invokes at the end of its run. It reuses the exported ROUTES array — one source of truth for the URL list — and renders a sitemap.xml where each <lastmod> is derived from git: every route maps to its source file(s), and the generator shells out to git log -1 --format=%cI -- <file> to get the last committer date. A blog post's lastmod is the last commit touching its markdown file; a tool page's is the last commit touching its component; pages sharing a template (the curl-to-X family) take the max across the template and its data file. The route-to-source map is explicit and fail-fast — add a route to the prerender list without mapping it and the sitemap build throws, naming the file to fix.
Why bother? Because the lazy alternative — stamping every URL with the build date — is worse than useless. Every deploy would claim all fifty pages changed today, which is a signal crawlers learn to ignore. Git already knows when each page's content actually changed; the generator just asks it. Two supporting decisions ride along: URLs are emitted with the same trailing slash the edge serves (the sitemap must agree with the canonical, same rule as above), and routes that carry a noindex meta tag are filtered out entirely — a sitemap entry for a page that asks not to be indexed is a contradiction, and contradictions are exactly what this pipeline exists to eliminate.
What I'd tell past me
The whole pipeline is one 20-line render function, one ~180-line prerender script, and one sitemap generator — small enough to read in full, which is the property I value most when SEO behaves strangely and I need to know exactly what HTML shipped. If your app already works as a SPA and your route list is finite and known at build time, you do not need to migrate frameworks to get real static HTML. You need renderToString, a head-extraction library, and a loop. The things that will actually bite you are not in the rendering — they are at the boundaries: the host's URL canonicalization disagreeing with your tags, and libraries that assume a browser exists. Handle those two explicitly, fail the build when a route stops rendering, and the rest is just writing files to disk. If you are also hardening what those responses look like on the wire, the HSTS deep-dive and the CSP explainer cover the header side of the same deployment.