← Blog/23 October 2026 · 5 min read

From in-browser Babel to pre-rendered pages: fixing a React site without a framework

Our studio site compiled 600 KB of JSX in every visitor's browser and showed search engines an empty page. Here is how we fixed both with one esbuild call and headless Chrome, without moving to Next.js.

ReactSEOPerformanceBuild tools

Our studio website started life the way a lot of prototypes do: one HTML file, React and ReactDOM from a CDN, and Babel Standalone compiling a big <script type="text/babel"> block in the browser. It is a lovely way to iterate. You save the file, refresh, and you are looking at the change. No toolchain, no node_modules, nothing to break.

It is also a terrible way to ship. When we looked at what a crawler actually received, the answer was sobering. The raw HTML of our home page contained zero words of visible text. Everything a visitor read, every product write-up, every paragraph about how we work, existed only after the browser had downloaded React's development build, downloaded Babel, and compiled about 600 KB of JSX on the main thread.

This post walks through how we fixed it in an afternoon, and why we deliberately did not reach for a framework.

What was actually wrong

We listed the problems before touching anything, because "rewrite it in Next.js" is the reflex answer and it is rarely the cheapest one.

  • No text in the HTML. Google can render JavaScript, but rendering is deferred and budgeted. Other crawlers, link previews and ad-network reviewers often do not render at all.
  • One URL for the whole site. Navigation used hash routes like #/projects and #/case/countasty. Everything after # is never sent to the server, so to a crawler our four case studies, services page and about page were all the same page.
  • Development builds in production. react.development.js is several times larger than the production build and runs extra checks on every render.
  • Compiling on the client. Babel Standalone is around 3 MB of JavaScript, and it re-compiled the entire app on every page view.
  • A 350 KB logo. The logo was a 2825 × 1597 PNG embedded as base64, displayed at 28 pixels tall. Once we started pre-rendering, it would have been copied into every page twice.

Step one: compile ahead of time

The JSX did not need a bundler. It used globals (React, ReactDOM) and had no imports, so the job was purely syntax transformation. esbuild does that in milliseconds:

npx esbuild --loader=jsx --target=es2019 --minify < app.jsx > app.js

We pulled the text/babel block out of the HTML into its own file, compiled it, and replaced the inline script with a normal <script src> tag. The file name includes a short hash of its contents, so browsers can cache it forever and still pick up new versions immediately.

Then we swapped the two development React builds for the production ones and recomputed the subresource integrity hashes, since we keep integrity attributes on every CDN script:

curl -sL https://unpkg.com/react@18.3.1/umd/react.production.min.js \
  | openssl dgst -sha384 -binary | openssl base64 -A

The Babel script tag simply disappeared. That alone removed roughly 3 MB of JavaScript from every visit.

Step two: real URLs instead of hash routes

The router was about fifteen lines: read location.hash, split on /, listen for hashchange. Switching it to the History API kept the same shape:

function parseHash() {
  const s = location.pathname.replace(/^\/|\/$/g, "");
  if (!s) return { name: "home" };
  const [name, slug] = s.split("/");
  return { name, slug };
}
function navTo(path) {
  history.pushState(null, "", pathFor(path));
  window.dispatchEvent(new PopStateEvent("popstate"));
}

Every link's href changed from #/projects to /projects/, so links work even before JavaScript loads and can be opened in a new tab. Old hash links still exist out in the world, in messages and bookmarks, so on load we rewrite #/case/whisper to /case/whisper/ with history.replaceState and nobody hits a dead end.

Step three: pre-render every page with headless Chrome

Here is the part where a framework would normally earn its keep: server-side rendering. We only needed the output, not a server, so we used the browser we already had.

A small Python build script starts a local HTTP server that answers every path with the compiled page, then asks Chrome to load each route and print the final DOM:

chrome --headless=new --virtual-time-budget=10000 \
  --dump-dom http://127.0.0.1:PORT/case/countasty/

--virtual-time-budget matters. It tells Chrome to fast-forward timers and wait until the page has settled, so React has rendered and the entrance animations have run before the DOM is captured.

From the dump we take the contents of <div id="root">, place it into the page template, set that page's <title>, a description taken from its first real paragraph, and a canonical URL, and write it to dist/case/countasty/index.html. On a static host, that file is served for /case/countasty/ with no rewrite rules at all.

The script fails loudly if any page renders fewer than 80 words. A silent empty page is exactly the bug we were fixing, so we did not want to be able to ship it again.

Why not hydrate?

When the real JavaScript loads, React's createRoot replaces the pre-rendered markup instead of hydrating it. That throws away a little work, but the site has no server-side data and no state worth preserving, and it avoids an entire class of hydration-mismatch bugs from animated components that render differently on the first frame. For a content site this is the right trade. For an app with heavy interactive state, it would not be.

The results

  • Visible words in the HTML went from 0 to about 6,500 across ten pages.
  • JavaScript per visit dropped by roughly 3 MB, because Babel and the development builds are gone.
  • The logo went from 350 KB to 18 KB by resizing it to four times its largest display size.
  • Every product and case study now has its own URL, title and description, and a sitemap that lists them.

The whole build is one Python file of about 150 lines plus esbuild. There is no framework to upgrade, and the source is still a single JSX file we can edit by hand.

When you should use a framework instead

This approach works because the site is small, static and owned by one team. If your pages depend on per-request data, if you have hundreds of routes, or if several people work on the front end every day, a framework with real server rendering will pay for itself. But if you have a prototype that grew into a production site, try the boring fix first. Compile ahead of time, give every page a real URL, and pre-render what you already have.

Next article
Online payments in a Flutter food app: what we learned wiring Paymob into Countasty →