← Journal
Build log / 04 · 6 min read

Prerendering a React SPA so it can actually be read

This site used to serve the same near-empty HTML file for every URL: a div, a script tag, and nothing else. Everything a visitor read was assembled in the browser afterwards. That is normal for a single-page app, and it is a worse default than most of us admit.

Fetch the old homepage with anything that does not execute JavaScript and you got no words back. Not fewer words — none. The same was true of every route: the products page, the journal, each article.

Modern crawlers can run JavaScript, so this often works out. But 'often works out' is doing a lot of load-bearing work in that sentence. Rendering is a second pass that may be delayed or skipped. Anything simpler than a full browser — a link preview, a scraper, a reviewer's tool, a text-mode client — gets an empty page and no way to know it missed anything.

There is also a plainer reason. A page that paints its content on the server shows up faster on a slow connection, because the words do not wait on a JavaScript bundle to download, parse and execute first.

The obvious fix is to adopt a framework that does server rendering. We did not want to rewrite the site to get static HTML out of it.

It turned out no new dependency was needed. React already ships renderToString, and React Router already ships StaticRouter. Between them you can render any route to a string at build time. So the build grew a third step: build the client, build a server bundle, then walk every route and write a real HTML file for each one.

The browser still hydrates exactly as before. Nothing about the interactive behaviour changed. The only difference is that the document arrives with its content already in it.

Once you are generating each page separately, an obvious gap appears: every URL had been sharing one title and one description. The contact page and a journal article advertised themselves identically.

That is bad for search, but it is worse as a signal of care. Identical titles across a site are what an unfinished project looks like. So the prerender step injects a real title and description per route, plus a canonical URL so the generated copies are not read as duplicates of one another. Article titles and descriptions come from the same post data the page renders, which means a new post cannot be published with its head tags forgotten.

The failure mode of a prerenderer is silent. If a route renders to nothing, you still get a file, the build still succeeds, and you have shipped an empty page while believing the opposite.

So the script asserts. After rendering each route it strips the tags, counts the remaining characters, and throws if a page produced less than a couple of hundred characters of text. It also throws if the root div is missing from the template, which would mean the content had nowhere to be injected.

That check has since caught exactly the class of mistake it was written for. A build that fails loudly is worth far more than one that quietly ships nothing.

You probably do not need to change frameworks. If your app is React and React Router, prerendering is a build script, not a migration, and you can add it in an afternoon.

Do add the assertion. The whole point is that you cannot see this problem by looking at the site in your own browser, because your browser runs the JavaScript and everything looks fine.

Follow on X →