Until October 2026, every page of this site reached the browser as the same nearly empty HTML file:
a title, a <div id="root"></div> and a script tag. React drew the page after the JavaScript
loaded. A visitor never noticed, but anything that reads the HTML without running the script saw
nothing: link previews, most AI crawlers, and Google until it got around to rendering the page.
Worse, every address returned that file with a 200, so /no-such-page looked like a real page too.
Now npm run build writes a real HTML file for every page, with its text, its own title,
description, link-preview tags and structured data, plus a sitemap and a 404 page that returns a
404. It took a second Vite build, a 120-line script, and a small CloudFront Function. No Next.js, no
framework, and no server: the site is still static files in S3.
It isn't free. The browser still renders every page from scratch instead of picking up the HTML, every page has to be listed by hand, and code that touches the browser has to cope with running at build time. Those are at the end.
What a single-page app sends
Vite builds a React app into one index.html and some JavaScript. Before this change, the HTML for
every page was:
<title>Rayman Jamal | Software Engineer</title>
...
<body>
<div id="root"></div>
<script type="module" src="/assets/index-....js"></script>
</body>
CloudFront served it for every address. S3 only had index.html at the root, so CloudFront was told
to answer "not found" with that file and a 200, and React Router took it from there:
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
(There was a second block for 403, which is what S3 actually answers for a missing key when the bucket can only be read through CloudFront.)
Three problems follow from that:
- Nothing in the HTML. Google does run JavaScript, but as a separate rendering step after it fetches the page. Link previews (Slack, LinkedIn, iMessage) and most AI crawlers read the HTML and stop.
- One title and description for every page. They were in
index.html, so every page, and every link to it, had the home page's. - Soft 404s. Every made-up address was a 200 with the home page's content. Google calls this a soft 404 and has to guess which pages are real.
The idea: render each page once, at build time
React can render a component tree to an HTML string with renderToString from
react-dom/server. Normally that runs on a server for each request. Run it once per page at build
time instead and write the strings into files, and you have a static site that's also a React app.
This is usually called pre-rendering, or static site generation.
The build is now three steps:
"build": "tsc -b && vite build && vite build --ssr src/entry-server.tsx --outDir build-ssr && node scripts/prerender.mjs"
vite build: the normal client build intobuild/, unchanged.vite build --ssr src/entry-server.tsx: a second build of the same app for Node, intobuild-ssr/.--ssrtells Vite to build for a server: no browser bundle, andnode_modulesleft as imports.node scripts/prerender.mjs: loads that build, renders every page, and writes the HTML files.
The server entry
The server build needs an entry that renders a given address. It's the same tree as the browser's
main.tsx, with React Router's StaticRouter (which takes the address as a prop) instead of
BrowserRouter (which reads it from the window):
// src/entry-server.tsx
import { StrictMode } from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom";
import App from "./App";
import { ThemeProvider } from "./components/ThemeContext";
export { pages, notFoundPage, canonicalUrl } from "./seo";
export function render(url: string) {
return renderToString(
<StrictMode>
<StaticRouter location={url}>
<ThemeProvider>
<App />
</ThemeProvider>
</StaticRouter>
</StrictMode>,
);
}
It also re-exports the list of pages, so the script gets them from the same build.
One list of pages
The routes in App.tsx say what React draws for an address, but not which addresses exist: one
route, /articles/:slug, covers every article. So src/seo.ts has a list with one entry per page:
its path, title, description, structured data, and optionally its own link-preview image.
export interface PageMeta {
path: string;
title: string;
description: string;
jsonLd: object;
image?: string;
ogType?: "article";
article?: { author: string; published: string; modified: string };
lastmod?: string;
}
Fixed pages are written out, and articles are mapped from the data the articles are built from, so
publishing one adds its page with no extra step. The same list drives the pre-rendering, the
sitemap, and the browser: when you move between pages, a small effect in App.tsx sets the title,
description and canonical link from it.
jsonLd is structured data in schema.org's vocabulary: the home page is a ProfilePage about a
Person (job title, skills, certifications, links), and each article is a BlogPosting with its
dates, word count and reading time. Search engines use it for rich results, and it states plainly
what an answer engine would otherwise have to infer from the text.
The script
scripts/prerender.mjs takes the client build's index.html as a template and, for each page:
- Replaces
<title>, the description and the canonical link (and leaves the canonical link out of the 404 page). - Adds the head tags: Open Graph and Twitter cards for link previews, article dates, the JSON-LD, and a preload for the self-hosted font.
- Puts
render(page.path)inside<div id="root">. - Writes it to
build/<path>/index.html, so/articles/what-is-transcodingbecomesbuild/articles/what-is-transcoding/index.html.
The core of it:
for (const page of pages) {
const file = page.path === "/" ? join(out, "index.html") : join(out, page.path, "index.html");
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, pageHtml(page, { indexable: true }));
}
writeFileSync(join(out, "404.html"), pageHtml(notFoundPage, { indexable: false }));
pageHtml does the replacing, and throws if the template is missing anything it expects to replace,
so a change to index.html can't silently produce pages without titles. The 404 page gets
<meta name="robots" content="noindex">.
Then it writes sitemap.xml from the same list, and deletes build-ssr/. The sitemap gives a
lastmod only where it's known: an article's last commit, never the build date, which would mark
every page as changed on every deploy.
In the browser: render, don't hydrate
The usual partner to renderToString is hydrateRoot: React takes over the existing HTML, attaches
its event handlers, and leaves the DOM alone. This site doesn't do that. main.tsx still uses
createRoot(...).render(...), which throws away what's inside #root and draws the page again.
The reason is the theme. The site is dark unless the visitor has chosen light, and that choice is in
localStorage, which doesn't exist at build time. So the pre-rendered HTML is always the dark
version. A small script in the <head> switches the page to light before it paints, so there's no
flash, but React's tree would still differ from the HTML: the theme toggle shows a moon instead of a
sun. Hydrating over a mismatch like that makes React report a hydration error and render in the
browser anyway.
Rendering from scratch avoids that. The HTML is identical to what React draws, apart from that icon, so a visitor sees no change. What it costs is the work: React builds every element again instead of reusing them. For a site this size that's quick, but on a large page it's time the browser spends before the page responds.
CloudFront: clean addresses and real 404s
The files are articles/what-is-transcoding/index.html, but the address is
/articles/what-is-transcoding. S3 behind CloudFront doesn't map one to the other the way a web
server does (only the root gets a default object), so a CloudFront Function does, on every request
before the cache:
if (uri.endsWith("/")) {
request.uri = uri + "index.html";
} else if (uri.split("/").pop().indexOf(".") === -1) {
request.uri = uri + "/index.html";
}
An address whose last part has no dot is a page, so it gets /index.html; anything with a dot
(/og.png, /sitemap.xml) is a file and passes through. The same function redirects www to the
bare domain and old addresses of pages that moved to their own sites, with a 301.
With every page a real file, a missing object is now a real 404:
# S3 answers 403 for a missing key (the OAC can't list the bucket), so map both.
custom_error_response {
error_code = 403
response_code = 404
response_page_path = "/404.html"
error_caching_min_ttl = 60
}
(and the same for 404). React Router's catch-all route renders the same "not found" page in the browser, so the HTML and the app agree.
Checking it
curl sees what a crawler that doesn't run JavaScript sees:
curl -s https://rrjamal.ca/articles/what-is-transcoding | grep -o '<title>[^<]*'
curl -s -o /dev/null -w '%{http_code}\n' https://rrjamal.ca/no-such-page # 404
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://www.rrjamal.ca/ # 301 https://rrjamal.ca/
That article's page is now about 20 KB of HTML with its full text in it, where before it was an
empty div. For link previews, pasting a link into Slack or LinkedIn's Post Inspector shows the
card. For structured data, Google's Rich Results Test reads the JSON-LD.
Google Search Console only has data for this site from the day the change went live, so there's no
before and after to compare. What it does show is the change working as intended. Pages were indexed
within days. Its "Why pages aren't indexed" list has no soft 404s. The largest entry, 15 addresses,
is "Alternative page with proper canonical tag": addresses that the canonical link folds into
another page, which on this site are the tag filters (/articles?tag=aws) and the slash-ended
duplicates. Two more are "Page with redirect", the www and moved-page redirects.
What it costs
- No hydration. As above, the browser redraws every page. Fine here; worth doing properly on a site with large pages or one that has to respond quickly on slow phones.
- Every page is listed by hand. A route that's missing from
pagesis never pre-rendered, so on the live site it's a 404, even though the app would draw it. That's the main thing a framework does for you: it finds the pages itself. - Code runs at build time, in Node. Anything that touches
window,documentorlocalStoragewhile rendering breaks the build or renders the wrong thing. Here that's one guard in the theme (if (typeof window === "undefined") return "dark";), and effects (useEffect), which never run duringrenderToString. - Client-only parts stay client-only. Diagrams are drawn by Mermaid in the browser, so the HTML has the diagram's source text, not the picture.
- The bundle still carries everything. Pre-rendering adds HTML, it doesn't remove JavaScript. The main bundle (about 450 KB, 145 KB compressed, in November 2026) includes every article's HTML, because the browser renders any page from it, so it grows with each article.
- The content is fixed at build time. Anything that changes needs a rebuild and a deploy. For a personal site that's the point; scheduled articles are handled by a daily build.
- Two addresses for each page.
/articles/xand/articles/x/both return the page. Each page's canonical link names the one without the slash, which is what search engines go by.
Why not Next.js, or something else?
Each of these would have done the job. The question was how much to change for it.
| Option | What it means here |
|---|---|
| Next.js (static export) | Moving to its router and file layout, and rewriting the routing. The most features, and the biggest change. |
| React Router's framework mode | React Router 7 can pre-render routes itself with a prerender option, but only in framework mode: its own Vite plugin, route modules and conventions in place of a plain Vite app. The closest fit if starting again. |
| Vike, or another Vite SSR plugin | Less to change than Next.js, but a new dependency that owns the build. |
| A headless browser at build time | Load each page in Chrome and save the result. No changes to the app, but slow, and a browser in CI. |
| This: a second Vite build and a script | About 120 lines of script and a 20-line entry. Nothing new to learn or update, and every step is visible. |
For a site of a dozen pages, already on Vite and React Router, the last row was the smallest change, and it's still the one I'd pick. A site with hundreds of pages, data from an API, or pages that should feel instant on slow devices should use a framework, which does the listing, hydration and splitting for you.