SEO for a React site, part 2: the deploy saga
The prerender worked beautifully on my laptop. Then I hit Publish and it broke four times in a row — three before I thought I was done, and one more that only showed up when Claude tried to read the live site. Here's the rabbit hole, the actual fixes, and what I changed in the skills so nobody else has to take this ride.

This post was written by my Replit AI agent, who did the building and the debugging. I asked the questions, made the calls, and decided when to stop chasing the wrong fix. We're writing this series together — and that's part of the story too.
Part 1 ended with a tidy conclusion: prerender every route, ship real HTML, get cited by AI. I wrote it confidently because the build worked on my laptop and the local preview looked perfect.
Then I clicked Publish, and the build failed. Then I fixed it, and it failed again. Then I fixed it a third way, and it failed a third time. Then I shipped a fourth fix and was so pleased with myself that I asked Claude to read the live site to confirm — and Claude came back saying every URL on the blog was returning the same homepage.
This is the story of what actually went wrong, why my first two "fixes" were wrong, why my fourth "we're done" wasn't, and what I changed in the skill so the next person who installs it skips this entire detour.
Why should I care?
Even if you've never heard of Puppeteer or Chromium, you've probably lived this story. You build something, it works perfectly on your computer, and then the moment you try to put it out in the world — it breaks. And not once. Four times, in four different ways. The last one was especially mean: the site looked fine to me, looked fine to anyone clicking around in a browser, and was completely invisible to every crawler and AI assistant — the exact thing this whole series exists to fix. This is that story, and the lesson at the end isn't really about Chrome or containers. It's about stopping to ask a better question when the obvious fixes keep failing, and about not trusting your own eyes when "does it work?" is really "does it work for the machines that read it?"
The setup, in one paragraph
The prerender step uses Puppeteer — a headless browser that loads each page of the site and saves the final HTML to disk. To do that, Puppeteer needs an actual Chrome (or Chromium) binary to drive. Locally I had one. In the production build container, apparently, I did not. Three times.
Attempt 1: "just install Chrome"
The first failure was clean and obvious:
Error: Could not find Chrome (ver. 131.0.6778.204).
So I added a small prebuild script that ran puppeteer browsers install chrome before the prerender. That downloads Chrome to a known cache directory. Logical. Reassuring. Built locally. Pushed. Hit Publish.
The download succeeded. The launch did not:
error while loading shared libraries: libglib-2.0.so.0:
cannot open shared object file: No such file or directory
Chrome is on disk. Chrome cannot run. The container it's running in is a stripped-down Linux image that doesn't include the system libraries Chrome needs to start. Downloading the binary doesn't help if the OS underneath can't host it.
Attempt 2: "use the Lambda-friendly one"
There's a well-known package called @sparticuz/chromium that was built precisely for this situation. It targets AWS Lambda, which has the same problem: a stripped-down container with no graphical libraries. The package vendors its own copies of the missing shared libraries alongside a Chromium binary tuned to use them.
I swapped to it, pushed, hit Publish.
error while loading shared libraries: libnspr4.so:
cannot open shared object file: No such file or directory
Different missing library. Same shape of failure. It turns out the Replit deploy container is even more stripped down than AWS Lambda — stripped past the point that sparticuz's bundled libs assume.
At this point I had two failed approaches and a growing suspicion that I was solving the wrong problem.
Stopping to think
The pattern was: every fix tried to bring its own Chrome to the deploy container, and every Chrome failed against a different missing system library. Each attempt was a new round of "guess what library is missing this time."
So I asked a different question. Not "how do I get Chrome to work in the deploy container?" — but "why does Chrome work fine in my dev container?"
The answer is that Replit's dev environment uses Nix, a package manager that installs software with all of its dependencies isolated together. The project already had chromium declared in its Nix config (replit.nix), which is why my local builds worked: Nix had installed Chromium and every shared library it needed, sealed in a self-contained directory.
And here is the thing I should have figured out an hour earlier: the deploy container inherits the same Nix environment as the dev container. That same Chromium binary, with the same bundled libraries, is sitting on disk in production too. I just wasn't using it.
The actual fix
One change. The prerender script no longer downloads anything, no longer ships a vendored binary. It just asks the operating system where Chromium lives:
import { execSync } from "node:child_process";
const executablePath = execSync("command -v chromium", {
encoding: "utf8",
}).trim();
That's it. In dev it finds the Nix-provided Chromium. In the deploy build it finds the same Nix-provided Chromium, because both environments share the same Nix package set. No download step. No vendored libraries. No "guess the missing dependency."
I pushed once more. The build went green. Claude tested the homepage from the outside and confirmed every Open Graph tag, every meta description, every JSON-LD block was readable without running a single line of JavaScript.
I called it done. I should not have.
Attempt 4: "okay now read the blog posts"
The whole point of this exercise was to get the blog indexed — not just the homepage. So I asked Claude to do one more pass: read /blog, then read each individual post URL, and tell me what title and content each one returned.
Claude came back politely confused:
Every route — /blog, /blog/seo-and-geo-for-a-react-site, /blog/seo-and-geo-part-2-the-deploy-saga — all serve the same homepage HTML with the homepage meta tags and homepage content.
In a browser, the blog looked fine. Click around, everything works. But to a crawler — to Claude, to Google, to anything reading the raw HTML — there was only one page on the entire site, repeated under every URL.
This is exactly the failure mode this whole series is trying to prevent. And I'd shipped it.
The wrong first reaction
My first instinct was "the prerender silently skipped the blog routes." So I checked the build output:
dist/public/blog/index.html ✓ exists
dist/public/blog/hello-world/index.html ✓ exists
dist/public/blog/seo-and-geo-for-a-react-site/index.html ✓ exists
dist/public/blog/seo-and-geo-part-2-the-deploy-saga/index.html ✓ exists
All there. Each file ~30 KB. Correct titles inside. The prerender wasn't the problem.
The wrong second reaction
So maybe the deploy didn't upload them? I curled the URLs directly:
$ curl https://tkb-hostinger-migration.replit.app/blog/index.html
→ 200 OK, correct blog index HTML
$ curl https://tkb-hostinger-migration.replit.app/blog/
→ 200 OK, homepage HTML
Same server. Same deploy. The file is uploaded, and you can read it — but only if you type its full path including index.html. Ask for /blog/, get the homepage. That's not a missing file. That's the server choosing the wrong file on purpose.
The actual cause, in plain English
Every single-page app needs one rule that says "if the requested URL doesn't match any file you have, serve /index.html and let the JavaScript figure out what to do." That's how client-side routing works. The scaffold I started from put that rule in by default:
from = "/*"
to = "/index.html"
Most web servers, when asked for a directory like /blog/, will automatically look inside that directory for an index.html and serve it. The Replit static host does not. To this host, /blog/ is just a URL it doesn't have a literal file for — and so the catch-all rule fires and serves the homepage instead.
A real file at /blog/index.html was sitting right there. The server just never thought to look for it, because the URL it was asked for was /blog/, not /blog/index.html. To the host, those are different things.
The fix
For each prerendered route, I added two explicit rules before the catch-all, telling the server exactly which file to serve for that URL:
from = "/blog/"
to = "/blog/index.html"
from = "/blog/seo-and-geo-part-2-the-deploy-saga/"
to = "/blog/seo-and-geo-part-2-the-deploy-saga/index.html"
... one pair for every prerendered route ...
from = "/*"
to = "/index.html" # only fires if nothing above matched
That's the runtime fix. But it has an obvious problem: every time I write a new blog post, I have to remember to add a new rule. If I forget, the new post silently serves the homepage and I might not notice for weeks, because in a browser it'll look fine.
So I also added a check at the end of the build script that reads the deploy config, compares it to the list of routes the prerender just generated, and fails the build with a copy-pasteable block of the missing rules. No new post can ship without its routing rule in place. The build itself now refuses to lie about being done.
What I changed in the skill
If you read part 1, you might have downloaded the react-vite-seo-prerender skill and tried it on your own site. The version of the skill that existed when I wrote part 1 would have walked you straight into the four failures I just described. So I updated it.
The new version:
- Tells you to install Chromium as a system Nix package up front, not as a JS dependency. That's the single line that makes the whole thing work in both environments.
- Resolves Chromium at runtime via
command -v chromiuminstead of hard-coding a path. Nix paths look like/nix/store/<long-hash>-chromium-<version>/bin/chromiumand the hash changes when the package is updated. Looking it up by name is stable; copying the path is not. - Keeps
@sparticuz/chromiumas an optional fallback for anyone using the skill on a non-Replit minimal container (AWS Lambda, Vercel). On Replit it never fires. Belt and suspenders. - Makes per-route rewrites mandatory, with a build-time guard. This is the new one. The skill now explains the directory-index gotcha, shows the rewrite shape, and ships a build script that fails loudly with copy-pasteable rule blocks if any prerendered route is missing.
- Has a "dead ends that look promising" section covering both the wrong Chromium turns and the false-summit moment where the homepage tests green but the rest of the site is still invisible. So the next person — or the next AI agent — doesn't burn time on the same wrong turns I did.
What I learned
Four things I want to keep, mostly for myself but worth saying out loud:
The dev environment is data, not just a place to type. When something works in dev and not in production, the dev environment is telling you something specific about what your code actually depends on. The right move was to read that, not to import yet another package.
"Built for serverless" is not the same as "built for your serverless." Sparticuz is excellent — it just wasn't built for the exact container shape I was deploying into. The general lesson: when a package targets a specific minimal environment, check whether your environment matches it before assuming.
Skills should learn from their own mistakes. I wrote the first version of that skill before I had ever tried to deploy it. The current version was written after four failed deploys taught me what the skill needed to say. Documentation that hasn't been tested in production is just a draft.
"Works in my browser" is the most expensive lie in web deploys. The rewrite bug existed because a browser's client-side router quietly papered over a broken server response. The same site looked perfect to me and to anyone clicking around, and was completely invisible to every crawler that mattered. The only honest test was curl — the rawest, dumbest possible HTTP request, with no JavaScript and no second chance. If your goal is to be readable by machines, you have to look at the site the way a machine does. Anything else is wishful thinking.
Where to next
The site is live at the Replit subdomain, the prerender works in production, and Claude can read everything from the outside. Next on the list: pointing my real domain (thekathleenbuilding.com) at it, and writing the third post in this series — what it actually looks like in the wild once the AI assistants start citing it.
If you read this far and you're working on a React + Vite site of your own, the skill now contains the entire recipe with the fix baked in. You should not have to do any of this twice.
A short coda: the cache that lied
Everything above was written by my Replit AI agent. This part is me — the human who directed the project.
This is the part I figured out myself, away from the Replit agent and the build logs. I'd run my site through one of those AI evaluator tools — you feed it a URL, it tells you how legible your site is to AI assistants — and the report came back essentially empty. Nothing it could see, nothing it could summarize. Which was strange, because the site looked fine to me in a browser.
On a hunch, I went back and gave the evaluator the Replit subdomain instead of the URL it had originally crawled. Same site, same HTML, different hostname. This time the report came back full of detail. The evaluator had been holding onto its earlier verdict against the first URL and serving me the stale answer no matter how many times I re-ran it. Handing it a hostname it had never seen before was the only way to make it actually look again.
That's when I started to suspect what was going on with my agent's verification step, too. After the rewrite fix landed, my agent asked Claude to read the live blog URLs to confirm everything worked. Claude said every URL was still serving the homepage. We tried a second Claude in a fresh conversation. Same answer. Meanwhile curl from a terminal was returning the correct pages.
So we asked ChatGPT instead. ChatGPT read the page and listed all three blog posts by title, dates included.
Two AI assistants saying the site was broken, a third saying it was fine, and a terminal command agreeing with the third. The difference wasn't the site — the site was correct. The difference was whose cache had a stale verdict in it.
So the new rule, for me and for anyone else doing this kind of work:
curlis the source of truth. Always check the raw response from a terminal first. Ifcurlsays the page is right, the page is right — whatever any chatbot tells you next is downstream of caching you don't control.- Try more than one LLM. If one assistant comes back with a "your site is broken" verdict, ask another. They have independent caches and independent crawl times. A disagreement between two models is almost always a cache disagreement, not a real bug. Two AIs agreeing is still not proof — but two AIs disagreeing is almost always a cache issue.
- A URL the cache has never seen will always fetch fresh. That was the trick that worked for me with the evaluator — hand it a different hostname (a temp Replit subdomain, a staging URL, anything new) and the cache has nothing to serve, so it has to fetch the real site. A junk query string like
?v=2works on the same principle for tools that key on the full URL. - A clean fix can look broken for hours. The further your fix is from where the cache lives, the longer the lag. Give it a beat before you panic.
None of this is in the skill itself — the skill ships working code. But it's now in the skill's "common pitfalls" list, and it's here too, because the next person doing a prerender fix is going to spend an hour thinking they failed when they actually succeeded.
This is a team sport. Working with the Agent I watched it fail, decided when to stop chasing the wrong fix, and figured out that the "broken" result was actually a caching ghost. That's the part of building with AI that nobody talks about yet — the human judgment calls between the automated steps. And honestly, it's the most interesting part.