All blog

We build this site in React, then delete the JavaScript

Engineering4 min read

This site is written in React, prerendered to static HTML, and then the build deletes the client bundle it just finished making. Nothing hydrates. Nothing needs to. Here is what that costs, what it buys, and the soft-404 disaster that made true believers of us.

The last step of our web build is a loop that deletes files the previous step just spent honest CPU time producing:

// The prerendered pages drop the module script, so the JS bundle is dead weight.
for (const output of result.outputs) {
  if (output.path.endsWith(".js") || output.path.endsWith(".js.map")) {
    await rm(output.path, { force: true });
  }
}

That's the whole trick. Everything interesting about it hides in the word dead, and I'll get there, though at this hour nothing gets anywhere quickly.

The shape of the build

bun build compiles the React app the ordinary way, the way ten million other builds did tonight. Then prerender.tsx walks the route registry in src/App.tsx and calls renderToString on everything: home page, blog, every post, the comparison pages, the exercise pages, the legal boilerplate nobody reads and everybody ships. Roughly 600 URLs, each written to its own index.html.

Then the prerenderer strips the module script out of every page:

.replace(/<script type="module"[^>]*><\/script>/, "")

No page requests the bundle. The bundle is therefore 300-odd kilobytes of parse-and-execute that not one soul on the internet will ever ask for, and the loop above escorts it off the disk. What ships is HTML and CSS. That's it. That's the stack.

Why not just use a static site generator, asks everyone, always

Because the routes were never the point; the data was. A blog post, a comparison table, an exercise page and an API reference are four renderers over four shapes, all wanting the same layout, the same typography, the same header. React is a perfectly good template language. It is only the runtime we had no use for, and it turns out you're allowed to leave the runtime at the store.

The route registry is the load-bearing wall. feeds.ts generates the sitemap and the RSS feed from the same registry, which means a page cannot exist without being listed and a listing cannot exist without a page. There is no second file to forget, and I have forgotten enough second files for one career. Content arrives through a Bun macro that reads the markdown directory at transpile time and inlines the parsed result as plain data. marked and node:fs never get near a browser bundle, on account of there being no browser bundle to get near.

The rule this imposes: interactivity has to be native

No JavaScript means every interaction is something HTML does on its own, which is less limiting than you'd fear and exactly as limiting as you'd suspect.

Accordions are <details> and <summary>. Tabs are anchors and :target. Anything that wants to be a controlled component becomes either a link to another prerendered page or a thing that does not exist. When somebody proposes a carousel, the answer was written down before they finished the sentence, which saves everyone a meeting.

One exception, and it earns its keep by being instructive. The Form Coach demo genuinely runs a pose model in the browser (there is no <details> element for that, I checked), so it's built as its own entry point, outside the bundle that gets deleted, fetched by a small inline loader when the section nears the viewport. One escape hatch, deliberately carved, reason documented in a comment above it. That is a different animal from "ship a framework runtime to every page in case someone, someday, needs a dropdown."

The bug that made converts of us

For a while the build wrote the standard Cloudflare Pages SPA fallback: /* /index.html 200. Everyone writes that line. It's in every tutorial. On a prerendered site it is also a machine for converting every typo on your domain into an indexable duplicate of your home page.

/asdf: 200, home page. /blog/typo: 200, home page. Every stale inbound link, every crawler sniffing for /wp-admin, all of them warmly received and handed the front page like a complimentary mint. That's a soft 404, and across 600 pages it is the express lane to convincing a search engine your site is mostly filler. Which, given what it then serves, it would be.

The fallback is gone. There's no client router to rescue, every route has its own file, and Cloudflare serves 404.html with an honest 404 status for everything else. The build now refuses to complete without one:

if (!notFound) throw new Error("no /404 route in the registry: refusing to ship a site with no 404 page");

An error, not a warning. A warning is a thing you read once, the night you add it, and never again as long as you live.

What it actually buys

Not the Lighthouse score, though the Lighthouse score is there, preening. The real return is a smaller surface for things to go wrong on. No hydration mismatches, because nothing hydrates. No "works locally, blank in production," because nothing runs after the HTML arrives. A page that renders in the build renders on a phone on a train in a tunnel, and there is a specific, quiet satisfaction in that which I'd have to be a better writer than I currently am at 1:30 in the morning to convey.

The cost is that the site can only do what HTML does. We've made our peace. The interesting engineering lives in the app, where a pose model runs on your phone and a coach reads a year of your training, not in a marketing page that needs a framework to draw a heading. It never needed the framework to draw the heading. None of them do. Don't get me started.

Related

The app's on-device pipeline, where the constraints are the opposite kind and considerably less forgiving, is in quantize the lifter, not the detector.

OneRep tracks workouts, food and progress, free and unlimited. The AI coach runs on a monthly allowance.