The build log said success while the generated HTML held nothing. A post-mortem of five silently failing spots in a Vite + React SPA — broken shell injection, a regex spanning records, shell-vs-runtime title conflicts, related links starving the long tail — plus a copy-paste build-output checklist.
We just finished an SEO overhaul of pokitx — a Vite + React single-page app with 117 online tools that generates a static HTML shell for every route at build time (bilingual, 416 pages total).
The part worth writing up isn't what we got right. It's that we found five things that had been failing silently — the build log reported success the whole time, no script ever threw, and yet the critical content simply wasn't in the generated HTML.
These bugs share one trait: the failure path and the success path both produce a page that looks fine. If you never grep the build output, you never find out.
Our post-build script writes static HTML for every route, baking a real <h1>, the tool documentation, FAQs and category links into #root so crawlers that don't run JS can read them. The injection looked like this:
html = html.replace(/<div id="root">\s*<\/div>/, `<div id="root">${SPLASH_HTML}${fallback}</div>`)
That regex requires #root to be an empty div. But at some point index.html got a branded splash screen and a hand-written homepage fallback placed inside it:
<div id="root">
<div id="app-splash" aria-hidden="true">…</div>
<div id="ssr-fallback">
<h1>… homepage headline …</h1>
…
</div>
</div>
So the regex never matched again. And when String.prototype.replace finds no match it doesn't throw — it returns the string unchanged. The script ran to completion, logged "wrote 416 static route shells", and everything looked healthy.
What actually shipped was all 416 pages inheriting that one hardcoded homepage fallback:
grep -ho "<h1>[^<]*</h1>" dist/tools/*/index.html | sort -u | wc -l
# 1
117 tool pages, one unique h1. The Chinese pages were serving English copy. Tool docs, FAQs, the pricing table we bake in for AI crawlers, the category link lists — none of it reached the HTML.
The fix scans for #root's real closing tag by matching div depth:
function replaceRootContent(html, inner) {
const OPEN = '<div id="root">'
const open = html.indexOf(OPEN)
if (open === -1) return null
const tag = /<\/?div\b[^>]*>/gi
tag.lastIndex = open + OPEN.length
let depth = 1, m
while ((m = tag.exec(html))) {
depth += m[0][1] === '/' ? -1 : 1
if (depth === 0) return html.slice(0, open) + OPEN + inner + '</div>' + html.slice(m.index + m[0].length)
}
return null
}
But the more important change was throwing when it doesn't match:
const injected = replaceRootContent(html, SPLASH_HTML + fallback)
if (!injected) throw new Error(`#root not found or unbalanced (route ${r.path})`)
A bug that silently degrades for months and a bug that turns your build red cost radically different amounts to live with.
Our tool registry is an array, and the build script pulls fields out of the source with a regex:
const re = /slug:\s*'([a-z0-9-]+)'[\s\S]*?category:\s*'([^']+)'[\s\S]*?name:\s*T\('…'\)[\s\S]*?desc:\s*T\('…'\)/g
This works fine while every entry has every field. It breaks the moment you add an optional one. [\s\S]*? is lazy but it does not respect record boundaries — an entry missing the field will happily match forward into the next entry and adopt its neighbour's value.
Nothing throws. You just get a handful of pages mysteriously titled after a different tool.
The fix is to establish boundaries first, then read fields inside them:
// Slice the array into one chunk per tool on the " { slug: '...'" line starts
const starts = [...txt.matchAll(/^ \{ slug: '([a-z0-9-]+)'/gm)]
const chunk = txt.slice(m.index, next ? next.index : txt.length)
// then read each field within the chunk — not found here means genuinely absent
The general lesson: when parsing structured text with regex, find the record boundaries before extracting values. A lazy quantifier that can cross a record boundary will bite you eventually.
<title>Static shells in an SPA come with an easily forgotten caveat: they only hold until JS executes.
Our shell wrote title A; once React mounted, setSeo() wrote title B. Google renders JS, so B is what gets indexed. That carefully optimized title in the shell was visible only to crawlers that don't execute JS.
In this overhaul we rewrote tool titles from "tool name + site name" into keyword-loaded ones:
Before: Merge PDF - PocketKit
After: Merge PDF Free — In Your Browser, No Upload | PocketKit
Had we changed only the shell and forgotten the runtime, Google would still see the old version — the whole exercise wasted.
So titles have to come from one source: the shell and the runtime must read the same data and use the same assembly logic. We moved the titles into a seoTitle field on the registry that both sides read, extracted the brand suffix into a constant, and left a "changing this requires changing that" comment in both places.
Checking this is easy: open the page, let it mount, and compare the tab title against what curl returns.
The obvious way to add related-tool links is to take the first N in the same category:
const related = sameCategory.slice(0, 8)
Fine for small categories. But our developer-tools category has 30 entries — so all 30 pages pointed at the same 8 tools, and the other 22 received zero inbound links. Equity piles up on the head while the long tail stays orphaned.
Walking forward cyclically from the tool's own position fixes it:
const at = sibs.findIndex((t) => t.slug === slug)
for (let k = 1; k < sibs.length && picked.length < 8; k++) picked.push(sibs[(at + k) % sibs.length])
Now every tool has an even in-degree and out-degree. Measured after the change:
inbound links per tool — min: 1 max: 8 zero-inbound: 0
The min: 1 comes from a category holding only two tools, which is expected.
In our registry, 32 tools had a carefully written keywords field. Meanwhile, in the build script:
tools.forEach((t) => add(`/tools/${t.slug}`, t.name, t.desc, t.name)) // only 4 args
games.forEach((g) => add(`/html-games/${g.slug}`, g.name, g.desc, g.name, g.keywords)) // 5 args
The fifth parameter of add() is keywords. Games passed it, comparison pages passed it — only the tool pages forgot. Those 32 keyword sets never once reached the static HTML.
Here's the honest part: fixing that bug is worth approximately zero in rankings. Google and Bing stopped reading meta keywords long ago. Its real value was exposing a mental error: we had written our keywords into a field that does not affect ranking, and then believed keywords were done.
Where those words actually belong is the <title> and <meta description>. So what we ultimately shipped wasn't the missing argument — it was rewriting all of those terms into the titles of all 117 tool pages.
Run these against your build output; most of the problems above surface in seconds:
# 1. Is h1 unique per page? "117 <h1>same sentence</h1>" means injection failed
grep -ho "<h1>[^<]*</h1>" dist/tools/*/index.html | sort | uniq -c | sort -rn | head
# 2. Does the unique title count equal the page count?
echo "unique: $(grep -ho '<title>[^<]*</title>' dist/tools/*/index.html | sort -u | wc -l) / $(ls dist/tools | wc -l)"
# 3. Is there actually a body in the shell? (print the first 600 chars after ssr-fallback)
node -e "const h=require('fs').readFileSync('dist/tools/pdf-merge/index.html','utf8');
const i=h.indexOf('ssr-fallback');console.log(h.slice(i,i+600).replace(/<[^>]+>/g,' '))"
# 4. Any duplicate canonical / hreflang injection?
grep -c 'rel="canonical"' dist/tools/pdf-merge/index.html # expect 1
# 5. Are the localized pages actually localized?
grep -o "<h1>[^<]*</h1>" dist/zh/index.html
Number 3 is the one worth making a habit. "The build succeeded" and "the output is correct" are different claims — and for something like a static shell, reading the artifact is the only reliable way to tell them apart.
One more conclusion worth recording. Our site is bilingual, with Chinese pages under /zh. After researching it we decided to spend nothing on Baidu, because two hard constraints stack:
So /zh continues, but its audience isn't Baidu — it's Chinese-language queries on Google, Bing in Chinese, overseas Chinese-speaking users, and the Chinese-language corpus that AI answer engines draw on.
Actually capturing Baidu traffic requires a domestic ICP filing plus domestic hosting. That's an infrastructure decision, not an SEO one. Until then, any Baidu SEO effort is a sunk cost.
All of these run entirely in your browser with nothing uploaded:
_headers and _redirects for Cloudflare Pages / Netlify plus vercel.json, including SPA fallback and security headers