I run a small AI headshot app on my own. React and TypeScript, hosted on Firebase Hosting. No Next.js. It speaks five languages: English, Spanish, French, German and Chinese.
Paste a German link to it into Slack and the preview card comes up in English.
I know exactly why. I wrote a comment explaining it. And I'm leaving it alone. Here's the setup, because I think the tradeoff is more useful to talk about than a tidy fix would be.
Languages live in the query string
There are no /de/ or /fr/ paths. Language is a query param, ?lang=de, picked up by i18next's browser language detector:
detection: {
order: ['querystring', 'navigator', 'htmlTag'],
lookupQuerystring: 'lang',
},
supportedLngs: ['en', 'es', 'fr', 'de', 'zh'],
Every page then emits its hreflang set through react-helmet-async:
{supportedLangs.map(l => (
<link key={`hreflang-${l}`} rel="alternate" hrefLang={l}
href={`${CANONICAL_BASE}${canonicalPath}?lang=${l}`} />
))}
<link rel="alternate" hrefLang="x-default" href={canonicalUrl} />
I picked query params because it meant zero router changes. I had a custom router already and didn't want to touch every route. Cheap at the time. You'll see what it cost.
Prerendering, the poor man's SSR
A pure SPA ships an empty <div id="root">, which is not what you want crawlers chewing on. So after the build, a script spins up a tiny static server, opens each route in headless Puppeteer, waits, and writes page.content() back into dist/ as a flat HTML file.
Twenty routes, give or take. It sounds trivial. It produced most of my SEO bugs.
Bug one: two of every meta tag. index.html has static fallback <meta> tags for the SPA shell. Helmet adds its own route-specific ones on render, marked data-rh="true", but it only manages tags it created. It never removes the originals. So every snapshot had two description tags, generic homepage one first. Some crawlers read the first and stop. The fix was a post-processing pass in the prerender script: collect every key Helmet declared, then strip the static duplicate of each.
Bug two: the right page with the wrong title. Articles load from Firestore in the browser, and the page's whole <Helmet> block waits on that fetch. Firestore keeps a long-lived connection open, so networkidle2 fired well before the article arrived. I added waitForSelector('article'). Still broken sometimes: the <article> would be in the DOM while Helmet hadn't caught up, so the snapshot had real content under the generic shell <title>.
What finally fixed it was asserting on the output, not the timing:
if (route.startsWith('/articles/') && html.includes(SHELL_TITLE_TAG)) {
throw new Error('still carrying the generic shell <title>');
}
If a snapshot comes out wrong, that route fails loudly and doesn't get written. The previous good file stays. I'd much rather ship yesterday's page than a quietly broken one.
(There was also a reCAPTCHA thing. App Check can't run headless, so the article query never resolved at all. Now the script pulls published articles through Firestore's public REST API and injects them with evaluateOnNewDocument before the page boots. A whole saga. Another post.)
Where the query string bites
Firebase Hosting rewrites match on path. /?lang=de and /?lang=es and / all get the same file, the English prerendered index.html. The German version only exists after JavaScript runs.
Googlebot runs JavaScript before indexing, so the hreflang signal for search is fine. Slack, iMessage, LinkedIn, Discord and friends don't. They read the raw HTML and get an English og:title.
Fixing it properly means per-locale static output plus a way to route on the query string, and Firebase rewrites can't do that by path alone. That's a build and infra change, not a one-liner. So the decision, as written in the comment above that hreflang block: accept it, and revisit if anyone actually starts sharing non-English links.
If I started over
Path-based locales, /de/..., from day one. The router changes I dodged would have been an afternoon. The workaround has now cost me more than that in comments alone.
And I'd write the "assert on the snapshot" check before the first prerender ever shipped. Timing waits feel like fixes. They're guesses.
The app is Stature AI, if you want to poke at the prerendered pages yourself.
Top comments (0)