Denshin / Blog / Engineering
Pre-rendering head tags for an SPA, and the regex that failed quietly
Crawlers that do not run JavaScript saw our homepage title on every URL. How we stamp per-route HTML at build time on S3 and CloudFront, and the fifteen minutes when a silent regex miss shipped the wrong canonical.
Denshin Engineering · Engineering Team · 16 October 2026 · 4 min read
This site is a single page app. Vite builds it, React renders it in the browser, and there is no server side rendering anywhere. For a person with a modern browser that is fine. For anything that reads the HTML without running JavaScript, it was a problem we did not notice for longer than we would like.
What crawlers were seeing
A single page app ships one index.html. The title, description and canonical tag in that file belong to the homepage, and React swaps them for the right ones after it loads. Google renders pages, so it mostly copes. Bing renders less reliably, and the AI crawlers, GPTBot, ClaudeBot, PerplexityBot and the rest, mostly do not render at all. They read the raw response and move on.
On top of that we serve from S3 through CloudFront using the S3 REST origin, which does not resolve directory indexes. A request for /services asks S3 for an object called services. There isn't one, S3 answers 403, CloudFront's error rule serves /index.html, and the crawler sees the homepage title on the services page. Also on the about page, and on every blog post.
To anything that did not run our JavaScript, every URL on the site looked like a copy of the homepage.
Stamping the head at build time
We kept the SPA and made the build produce a real HTML file per public route with the right head already in it. A Vite plugin takes the built index.html and, for each route, writes dist/<route>/index.html with the title, meta description, canonical, Open Graph and Twitter tags replaced, plus a BreadcrumbList block of JSON-LD. The body is the same app shell. React boots and takes over as usual.
Blog posts live in the database rather than the repo, so the build fetches the published list from the API and stamps a page for each one with its own title and description. The same list feeds the sitemap, so a new post gets a sitemap entry and a crawlable head in the same deploy.
The CloudFront Function that already sat in front of the site then rewrites clean paths: /services becomes /services/index.html before the request reaches S3. Anything with a file extension passes straight through, and paths without a pre-rendered file fall back to the SPA as before.
Two small details we would repeat. The JSON-LD is serialised with every < escaped as <, so a post title containing </script> cannot close the script tag early. And the stamped tags carry a data-static-seo attribute, so the app can drop them once it renders its own and a hydrated page never declares the same schema twice.
The quiet miss
The stamping works by regex. Find the canonical <link> in the template, replace it with the route's version. Same for the title and each meta tag. This is fine until somebody edits index.html, which in September somebody did. One tag no longer matched its pattern, and String.replace did exactly what it is documented to do when nothing matches: it returned the input unchanged.
No error and no warning. The build passed, the deploy went out, and for about fifteen minutes the inner pages of the site shipped the homepage's canonical URL. A canonical pointing at the homepage is close to the worst thing you can tell a search engine about a page, because it asks for that page to be folded into another one.
It was spotted after the deploy, by a person looking at HTML, not by anything automated. That was the real bug.
Making it loud
The fix was a few lines. Every swap is declared as a name, a pattern and a replacement, and the stamp checks that each pattern matched before moving on. If one did not, it throws with the tag's name and the build fails. Roughly:
for (const [name, pattern, replacement] of swaps) {
if (!pattern.test(html)) {
throw new Error(`[head-stamp] ${name} tag not found in index.html`)
}
html = html.replace(pattern, replacement)
}
A failed build is annoying for five minutes. A green build that ships the wrong canonical to every page is worse, and nothing would have told us.
The general version of this applies well beyond SEO: any find and replace that can miss should be loud when it misses. Config templating, string patches over generated files, a sed in a deploy script. They share one failure mode, which is succeeding at doing nothing. We hold timestamps to a similar standard after a different bug that failed quietly.
Would server side rendering have avoided all this? Yes, and it would have brought a server, cold starts and a different deploy story to a marketing site that changes a few times a month. For us, a build step that writes static files and refuses to write wrong ones is the better trade. If you care about how AI search reads your pages, this is also the unglamorous groundwork under everything in our notes on generative engine optimisation.
Tags: SEO, Vite, React, CloudFront, Postmortem
All posts · Work with Denshin