<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Stature AI]]></title><description><![CDATA[Stature AI]]></description><link>https://stature-ai.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Mon, 02 Nov 2026 20:21:02 GMT</lastBuildDate><atom:link href="https://stature-ai.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[My German links unfurl in English, and I decided that's OK (for now)]]></title><description><![CDATA[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…]]></description><link>https://stature-ai.hashnode.dev/my-german-links-unfurl-in-english-and-i-decided-that-s-ok-for-now</link><guid isPermaLink="true">https://stature-ai.hashnode.dev/my-german-links-unfurl-in-english-and-i-decided-that-s-ok-for-now</guid><category><![CDATA[React]]></category><category><![CDATA[SEO]]></category><category><![CDATA[webdev]]></category><category><![CDATA[Firebase]]></category><dc:creator><![CDATA[Michael Dixon]]></dc:creator><pubDate>Fri, 23 Oct 2026 17:47:15 GMT</pubDate><content:encoded><![CDATA[<p>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.</p>
<p>Paste a German link to it into Slack and the preview card comes up in English.</p>
<p>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.</p>
<h2>Languages live in the query string</h2>
<p>There are no <code>/de/</code> or <code>/fr/</code> paths. Language is a query param, <code>?lang=de</code>, picked up by i18next's browser language detector:</p>
<pre><code class="language-ts">detection: {
  order: ['querystring', 'navigator', 'htmlTag'],
  lookupQuerystring: 'lang',
},
supportedLngs: ['en', 'es', 'fr', 'de', 'zh'],
</code></pre>
<p>Every page then emits its hreflang set through react-helmet-async:</p>
<pre><code class="language-tsx">{supportedLangs.map(l =&gt; (
  &lt;link key={`hreflang-${l}`} rel="alternate" hrefLang={l}
        href={`${CANONICAL_BASE}${canonicalPath}?lang=${l}`} /&gt;
))}
&lt;link rel="alternate" hrefLang="x-default" href={canonicalUrl} /&gt;
</code></pre>
<p>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.</p>
<h2>Prerendering, the poor man's SSR</h2>
<p>A pure SPA ships an empty <code>&lt;div id="root"&gt;</code>, 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 <code>page.content()</code> back into <code>dist/</code> as a flat HTML file.</p>
<p>Twenty routes, give or take. It sounds trivial. It produced most of my SEO bugs.</p>
<p><strong>Bug one: two of every meta tag.</strong> <code>index.html</code> has static fallback <code>&lt;meta&gt;</code> tags for the SPA shell. Helmet adds its own route-specific ones on render, marked <code>data-rh="true"</code>, but it only manages tags it created. It never removes the originals. So every snapshot had two <code>description</code> 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.</p>
<p><strong>Bug two: the right page with the wrong title.</strong> Articles load from Firestore in the browser, and the page's whole <code>&lt;Helmet&gt;</code> block waits on that fetch. Firestore keeps a long-lived connection open, so <code>networkidle2</code> fired well before the article arrived. I added <code>waitForSelector('article')</code>. Still broken sometimes: the <code>&lt;article&gt;</code> would be in the DOM while Helmet hadn't caught up, so the snapshot had real content under the generic shell <code>&lt;title&gt;</code>.</p>
<p>What finally fixed it was asserting on the output, not the timing:</p>
<pre><code class="language-js">if (route.startsWith('/articles/') &amp;&amp; html.includes(SHELL_TITLE_TAG)) {
  throw new Error('still carrying the generic shell &lt;title&gt;');
}
</code></pre>
<p>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.</p>
<p>(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 <code>evaluateOnNewDocument</code> before the page boots. A whole saga. Another post.)</p>
<h2>Where the query string bites</h2>
<p>Firebase Hosting rewrites match on path. <code>/?lang=de</code> and <code>/?lang=es</code> and <code>/</code> all get the same file, the English prerendered <code>index.html</code>. The German version only exists after JavaScript runs.</p>
<p>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 <code>og:title</code>.</p>
<p>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.</p>
<h2>If I started over</h2>
<p>Path-based locales, <code>/de/...</code>, 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.</p>
<p>And I'd write the "assert on the snapshot" check before the first prerender ever shipped. Timing waits feel like fixes. They're guesses.</p>
<hr />
<p><em>The app is <a href="https://stature-ai.com">Stature AI</a>, if you want to poke at the prerendered pages yourself.</em></p>
<p>Curious how it performs? Try a free preview at <a href="https://stature-ai.com">stature-ai.com</a>, about 2 minutes, no card.</p>
]]></content:encoded></item></channel></rss>