<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Michael Dixon</title>
    <description>The latest articles on DEV Community by Michael Dixon (@statureai).</description>
    <link>https://dev.to/statureai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4183023%2F86a8c814-d323-45bc-a13d-aa613aa6ab05.png</url>
      <title>DEV Community: Michael Dixon</title>
      <link>https://dev.to/statureai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/statureai"/>
    <language>en</language>
    <item>
      <title>My referral codes are just user IDs. The hard part was the order transaction.</title>
      <dc:creator>Michael Dixon</dc:creator>
      <pubDate>Tue, 13 Oct 2026 21:03:55 +0000</pubDate>
      <link>https://dev.to/statureai/my-referral-codes-are-just-user-ids-the-hard-part-was-the-order-transaction-10p3</link>
      <guid>https://dev.to/statureai/my-referral-codes-are-just-user-ids-the-hard-part-was-the-order-transaction-10p3</guid>
      <description>&lt;p&gt;When I added referrals to my app (AI headshots; React on the front, Firebase Functions and Firestore behind), I skipped the referrals collection. No code generator either. A referral code is &lt;code&gt;REF-&lt;/code&gt; plus the referrer's Firestore user ID.&lt;/p&gt;

&lt;p&gt;That part took ten minutes. The order endpoint it plugs into took a lot longer to get right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code is the user
&lt;/h2&gt;

&lt;p&gt;Redeeming looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;couponCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REF-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;refUid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;couponCode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;REF-&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refUid&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;referrerDoc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refUid&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;referrerDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 20% off&lt;/span&gt;
      &lt;span class="nx"&gt;referrerUid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;refUid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing to generate, nothing to expire, no orphan rows when someone deletes their account.&lt;/p&gt;

&lt;p&gt;Yes, if you know someone's UID you can guess their code. A referral code exists to be shared, so I don't care. I would not do this for anything meant to be secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three writes, one transaction
&lt;/h2&gt;

&lt;p&gt;When a referred order completes, the buyer gets their credits, the referrer gets 20 bonus credits, and an order record gets written. If any one of those lands without the others, I get a support email. So they all go inside one &lt;code&gt;db.runTransaction&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The payment side: the client finishes PayPal checkout and sends me the PayPal order ID. I verify it with PayPal server-side, checking the amount matches the price I calculated (after the referral discount). Then the transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replay bug
&lt;/h2&gt;

&lt;p&gt;What happens if the same PayPal order ID shows up twice? A double-click, a retry, someone replaying the request on purpose. Without protection, that's double credits for the buyer &lt;em&gt;and&lt;/em&gt; double credits for the referrer. Nothing crashes. You'd only find out when Firestore and PayPal stop agreeing.&lt;/p&gt;

&lt;p&gt;My first guard was a query before the transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paypalOrderID&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;==&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paypalOrderID&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;already processed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That catches the boring case. It doesn't catch two requests racing, because both can run the query, both see nothing, and both go on to write.&lt;/p&gt;

&lt;p&gt;The real fix was making the PayPal order ID the order document's ID and checking it &lt;em&gt;inside&lt;/em&gt; the transaction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paypalOrderID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;orderDoc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderRef&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderDoc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;This payment has already been processed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firestore won't let both of two racing transactions commit on top of the same read. The loser gets retried, re-reads, sees the order now exists, and bails. I kept the outer query anyway. It's a cheap early exit, and it skips a PayPal API call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The coupon sitting next to it
&lt;/h2&gt;

&lt;p&gt;Same endpoint, different headache. Regular coupons have usage limits. I checked the limit before the transaction, but between that check and the write, another order could take the last use. And PayPal has already captured at the discounted price by then.&lt;/p&gt;

&lt;p&gt;So the coupon gets re-read inside the transaction (Firestore wants all reads before writes, so it sits right after the order check), and if the limit's been hit, the order is rejected. The usage increment happens in the same transaction as the order record, so a failed order never burns a use. Rejecting a paid order isn't great. Honoring a discount I can't justify is worse.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;Write the "same request twice, concurrently" test first. I found the race by staring at the code, not by a test catching it, and that's luck, not process.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This all lives in the order flow of &lt;a href="https://stature-ai.com" rel="noopener noreferrer"&gt;Stature AI&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>My German links unfurl in English, and I decided that's OK (for now)</title>
      <dc:creator>Michael Dixon</dc:creator>
      <pubDate>Tue, 13 Oct 2026 21:03:34 +0000</pubDate>
      <link>https://dev.to/statureai/my-german-links-unfurl-in-english-and-i-decided-thats-ok-for-now-1143</link>
      <guid>https://dev.to/statureai/my-german-links-unfurl-in-english-and-i-decided-thats-ok-for-now-1143</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Paste a German link to it into Slack and the preview card comes up in English.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Languages live in the query string
&lt;/h2&gt;

&lt;p&gt;There are no &lt;code&gt;/de/&lt;/code&gt; or &lt;code&gt;/fr/&lt;/code&gt; paths. Language is a query param, &lt;code&gt;?lang=de&lt;/code&gt;, picked up by i18next's browser language detector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;detection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;querystring&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;htmlTag&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="nx"&gt;lookupQuerystring&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lang&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="nx"&gt;supportedLngs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zh&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every page then emits its hreflang set through react-helmet-async:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;supportedLangs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;link&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`hreflang-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hrefLang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;CANONICAL_BASE&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;canonicalPath&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?lang=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;link&lt;/span&gt; &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hrefLang&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;canonicalUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerendering, the poor man's SSR
&lt;/h2&gt;

&lt;p&gt;A pure SPA ships an empty &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt;, 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 &lt;code&gt;page.content()&lt;/code&gt; back into &lt;code&gt;dist/&lt;/code&gt; as a flat HTML file.&lt;/p&gt;

&lt;p&gt;Twenty routes, give or take. It sounds trivial. It produced most of my SEO bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug one: two of every meta tag.&lt;/strong&gt; &lt;code&gt;index.html&lt;/code&gt; has static fallback &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tags for the SPA shell. Helmet adds its own route-specific ones on render, marked &lt;code&gt;data-rh="true"&lt;/code&gt;, but it only manages tags it created. It never removes the originals. So every snapshot had two &lt;code&gt;description&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bug two: the right page with the wrong title.&lt;/strong&gt; Articles load from Firestore in the browser, and the page's whole &lt;code&gt;&amp;lt;Helmet&amp;gt;&lt;/code&gt; block waits on that fetch. Firestore keeps a long-lived connection open, so &lt;code&gt;networkidle2&lt;/code&gt; fired well before the article arrived. I added &lt;code&gt;waitForSelector('article')&lt;/code&gt;. Still broken sometimes: the &lt;code&gt;&amp;lt;article&amp;gt;&lt;/code&gt; would be in the DOM while Helmet hadn't caught up, so the snapshot had real content under the generic shell &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What finally fixed it was asserting on the output, not the timing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/articles/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SHELL_TITLE_TAG&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;still carrying the generic shell &amp;lt;title&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;(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 &lt;code&gt;evaluateOnNewDocument&lt;/code&gt; before the page boots. A whole saga. Another post.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the query string bites
&lt;/h2&gt;

&lt;p&gt;Firebase Hosting rewrites match on path. &lt;code&gt;/?lang=de&lt;/code&gt; and &lt;code&gt;/?lang=es&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; all get the same file, the English prerendered &lt;code&gt;index.html&lt;/code&gt;. The German version only exists after JavaScript runs.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;og:title&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  If I started over
&lt;/h2&gt;

&lt;p&gt;Path-based locales, &lt;code&gt;/de/...&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;And I'd write the "assert on the snapshot" check before the first prerender ever shipped. Timing waits feel like fixes. They're guesses.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The app is &lt;a href="https://stature-ai.com" rel="noopener noreferrer"&gt;Stature AI&lt;/a&gt;, if you want to poke at the prerendered pages yourself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>firebase</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
