<?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: Léo Guillaume (Dibodev)</title>
    <description>The latest articles on DEV Community by Léo Guillaume (Dibodev) (@dibodev).</description>
    <link>https://dev.to/dibodev</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%2F4083948%2F811962fa-9a33-43e6-b381-b1d09afe1005.jpg</url>
      <title>DEV Community: Léo Guillaume (Dibodev)</title>
      <link>https://dev.to/dibodev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dibodev"/>
    <language>en</language>
    <item>
      <title>My French homepage showed up in English on Google. A default Nuxt i18n option was the reason.</title>
      <dc:creator>Léo Guillaume (Dibodev)</dc:creator>
      <pubDate>Sun, 27 Sep 2026 17:10:00 +0000</pubDate>
      <link>https://dev.to/dibodev/my-french-homepage-showed-up-in-english-on-google-a-default-nuxt-i18n-option-was-the-reason-56c1</link>
      <guid>https://dev.to/dibodev/my-french-homepage-showed-up-in-english-on-google-a-default-nuxt-i18n-option-was-the-reason-56c1</guid>
      <description>&lt;p&gt;This week I searched for my own brand on Google, and my French homepage came up with an &lt;strong&gt;English title&lt;/strong&gt; and a &lt;strong&gt;"Translate this page"&lt;/strong&gt; link under it.&lt;/p&gt;

&lt;p&gt;My site is French first. The homepage at &lt;code&gt;/&lt;/code&gt; serves French, the English version lives at &lt;code&gt;/en&lt;/code&gt;, and the Spanish one at &lt;code&gt;/es&lt;/code&gt;. So why was Google showing the English page for the French URL?&lt;/p&gt;

&lt;p&gt;The site is a static Nuxt 4 build (SSG) with &lt;code&gt;@nuxtjs/i18n&lt;/code&gt; and the &lt;code&gt;prefix_except_default&lt;/code&gt; strategy. I started with the boring checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything looked right
&lt;/h2&gt;

&lt;p&gt;The raw HTML of &lt;code&gt;/&lt;/code&gt; was French:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://dibodev.fr/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'lang="[a-z]*"\|&amp;lt;title&amp;gt;[^&amp;lt;]*&amp;lt;/title&amp;gt;'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
&lt;span class="c"&gt;# lang="fr"&lt;/span&gt;
&lt;span class="c"&gt;# &amp;lt;title&amp;gt;Dibodev — Développeur Web Freelance à Rennes&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The canonical pointed to &lt;code&gt;/&lt;/code&gt;, and the hreflang tags were correct: &lt;code&gt;fr-FR&lt;/code&gt; → &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;en-US&lt;/code&gt; → &lt;code&gt;/en&lt;/code&gt;, &lt;code&gt;es-ES&lt;/code&gt; → &lt;code&gt;/es&lt;/code&gt;, &lt;code&gt;x-default&lt;/code&gt; → &lt;code&gt;/&lt;/code&gt;. Even with a Googlebot user agent, the server returned the same French page with a 200.&lt;/p&gt;

&lt;p&gt;So the server side was fine. The problem had to be in the browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproducing it with a browser in English
&lt;/h2&gt;

&lt;p&gt;Google doesn't just download your HTML: it renders the page in a headless Chrome and executes your JavaScript. A crawler doesn't tell you it speaks French. So I opened the homepage in Playwright with an English browser locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;playwright.sync_api&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sync_playwright&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;sync_playwright&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chromium&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;locale&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fr-FR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;en-US&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;new_page&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;goto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://dibodev.fr/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_until&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;networkidle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fr-FR -&amp;gt; https://dibodev.fr/ | Dibodev — Développeur Web Freelance à Rennes
en-US -&amp;gt; https://dibodev.fr/en | Dibodev — Freelance Web Developer in Rennes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it was. Any browser set to English landed on &lt;code&gt;/&lt;/code&gt;, got the French HTML, and was &lt;strong&gt;redirected to &lt;code&gt;/en&lt;/code&gt; right after hydration&lt;/strong&gt;. Same result with a Googlebot user agent. As far as the renderer was concerned, the content of &lt;code&gt;/&lt;/code&gt; was the English page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The culprit: a default option
&lt;/h2&gt;

&lt;p&gt;I never asked for a language redirect. But &lt;code&gt;@nuxtjs/i18n&lt;/code&gt; has a &lt;code&gt;detectBrowserLanguage&lt;/code&gt; option, and it is &lt;strong&gt;enabled by default&lt;/strong&gt;. By default it only acts on the root path, and it remembers the choice in a cookie. For real visitors, that's a nice touch: an English speaker lands on the English site.&lt;/p&gt;

&lt;p&gt;On a prerendered site, though, the redirect can't happen on the server: the HTML is already generated. It happens in the browser, once the app is running. And a headless browser with an English locale and no cookie gets the redirect every single time.&lt;/p&gt;

&lt;p&gt;My config never mentioned the option:&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="c1"&gt;// nuxt.config.ts (before, simplified)&lt;/span&gt;
&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;locales&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="na"&gt;code&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="na"&gt;language&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-FR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file&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.json&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="na"&gt;code&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="na"&gt;language&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-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file&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.json&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="na"&gt;code&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="na"&gt;language&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-ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;file&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.json&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;defaultLocale&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="nx"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prefix_except_default&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;h2&gt;
  
  
  The fix: one line
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// nuxt.config.ts (after)&lt;/span&gt;
&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...same as before&lt;/span&gt;
  &lt;span class="nl"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prefix_except_default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// No redirect to the browser language: crawlers render in English and were sent from / to /en.&lt;/span&gt;
  &lt;span class="nx"&gt;detectBrowserLanguage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;Visitors can still switch languages with the selector, and search engines already know about every version through hreflang. That's also what Google recommends: don't redirect users to a language version based on their browser, link the versions and let people choose.&lt;/p&gt;

&lt;p&gt;After deploying, the same Playwright script gives:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fr-FR -&amp;gt; https://dibodev.fr/ | Dibodev — Développeur Web Freelance à Rennes
en-US -&amp;gt; https://dibodev.fr/ | Dibodev — Développeur Web Freelance à Rennes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why I care: AI search too
&lt;/h2&gt;

&lt;p&gt;I noticed this because I'm working on how my site shows up in AI answers (ChatGPT, Copilot, Perplexity, Google's AI mode). Copilot and ChatGPT lean on Bing's index, and Bing renders pages with a browser too. A homepage that "becomes English" when rendered is exactly the kind of mixed signal that makes a small French business harder to recommend for French queries.&lt;/p&gt;

&lt;p&gt;It's the second rendering trap I found on this site in two days. The first one: some project descriptions were rendered client-side, and AI crawlers like GPTBot or PerplexityBot don't run JavaScript at all, so for them that text simply didn't exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@nuxtjs/i18n&lt;/code&gt; enables &lt;code&gt;detectBrowserLanguage&lt;/code&gt; by default. On a static or SEO-critical site, decide explicitly whether you want it.&lt;/li&gt;
&lt;li&gt;Test your pages with a browser in another language, not just with &lt;code&gt;curl&lt;/code&gt;: redirects after hydration don't show up in the raw HTML.&lt;/li&gt;
&lt;li&gt;Crawlers render your site without your cookies and often without your language. Check what &lt;em&gt;they&lt;/em&gt; get, not what you get.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm asking Google to recrawl the homepage, and I'll update this post when the French title is back in the results.&lt;/p&gt;




&lt;p&gt;I'm a freelance developer in Rennes, France, building custom business tools and websites for small businesses. The site this happened on, and more write-ups like this one: &lt;a href="https://dibodev.fr" rel="noopener noreferrer"&gt;dibodev.fr&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nuxt</category>
      <category>vue</category>
      <category>seo</category>
      <category>i18n</category>
    </item>
    <item>
      <title>My Nuxt pages were prerendered. Why were they still calling the CMS in the browser?</title>
      <dc:creator>Léo Guillaume (Dibodev)</dc:creator>
      <pubDate>Sat, 26 Sep 2026 11:47:17 +0000</pubDate>
      <link>https://dev.to/dibodev/my-nuxt-pages-were-prerendered-why-were-they-still-calling-the-cms-in-the-browser-3046</link>
      <guid>https://dev.to/dibodev/my-nuxt-pages-were-prerendered-why-were-they-still-calling-the-cms-in-the-browser-3046</guid>
      <description>&lt;p&gt;Last week, Google Search Console flagged three of my five newest blog posts: &lt;strong&gt;"Duplicate, Google chose different canonical than user"&lt;/strong&gt;. That happens. What doesn't usually happen is Google picking the &lt;em&gt;same&lt;/em&gt; canonical for all three: an older, unrelated post. A post about B2B software pricing, one about time tracking and one about online booking for driving schools, all folded into an article about small businesses in Rennes.&lt;/p&gt;

&lt;p&gt;Three different topics collapsing into one URL doesn't look like "similar content". It looks like Google saw the same page three times.&lt;/p&gt;

&lt;p&gt;My site is a static Nuxt 4 build (SSG) with Storyblok as the CMS. So I started with the boring checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each URL returns a 200 with its own &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt;, canonical and hreflang tags.&lt;/li&gt;
&lt;li&gt;The static HTML contains the full article.&lt;/li&gt;
&lt;li&gt;Search Console's "Test live URL" renders every article correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All fine. Then I opened the Network tab on one of those prerendered articles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two CMS calls on every page view
&lt;/h2&gt;

&lt;p&gt;The article was already in the HTML, yet the browser called the Storyblok API twice on every visit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.storyblok.com/v2/cdn/spaces/me?token=…
GET https://api.storyblok.com/v2/cdn/stories/blog%2F&amp;lt;slug&amp;gt;?token=…&amp;amp;cv=…
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The culprit was how the page loaded its data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- pages/blog/[slug].vue (before, simplified) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRoute&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;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&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="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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;article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ref&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Article&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;try&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;response&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;StoryblokArticleService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getArticleBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapStoryblokArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;story&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nf"&gt;createError&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;statusMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Article not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fatal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A top-level &lt;code&gt;await&lt;/code&gt; in &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; does run during prerendering, so the generated HTML is complete. But &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; runs &lt;strong&gt;again in the browser&lt;/strong&gt; when the page hydrates, and nothing tells Nuxt that this data already exists. &lt;code&gt;useAsyncData&lt;/code&gt; and &lt;code&gt;useFetch&lt;/code&gt; store their result in the page payload; a bare &lt;code&gt;await&lt;/code&gt; doesn't. So every visit fetched the story again, plus the &lt;code&gt;spaces/me&lt;/code&gt; call my service uses to get Storyblok's cache version.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;catch&lt;/code&gt; made it worse. If that browser request failed (rate limit, flaky network, a blocked request), the fatal 404 replaced a perfectly good prerendered article with the error page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it can matter for indexing
&lt;/h2&gt;

&lt;p&gt;Googlebot renders JavaScript. It fetches the HTML, then runs the app like a browser would, often for many URLs in a row. If the CMS call fails while it renders, every affected URL shows the same error screen. The same content on several URLs is exactly what gets clustered as duplicates, with one canonical picked for the whole group.&lt;/p&gt;

&lt;p&gt;To be honest, I can't prove that's what happened here. The live test renders fine today, and older posts running the same code did get indexed. But it's a real failure mode, the fix is small, and it removes two API calls from every page view anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: let the payload do its job
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight vue"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- pages/blog/[slug].vue (after, simplified) --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt; &lt;span class="na"&gt;setup&lt;/span&gt; &lt;span class="na"&gt;lang=&lt;/span&gt;&lt;span class="s"&gt;"ts"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;route&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRoute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useI18n&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;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&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="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;useAsyncData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`blog-article-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&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;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;StoryblokArticleService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLocalizedArticle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&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="nf"&gt;createError&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;statusMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Article not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fatal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="k"&gt;script&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;During &lt;code&gt;nuxt generate&lt;/code&gt;, the handler runs once and its result is written to &lt;code&gt;/blog/&amp;lt;slug&amp;gt;/_payload.json&lt;/code&gt;. When the page hydrates, and when you navigate to a prerendered route client-side, Nuxt reads that payload instead of running the handler again. The service now returns &lt;code&gt;null&lt;/code&gt; instead of throwing, and since the browser reads the payload, the 404 only fires when the story really doesn't exist at build time.&lt;/p&gt;

&lt;p&gt;Two details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The key must be identical on the server and in the browser, and unique per page (locale + slug here).&lt;/li&gt;
&lt;li&gt;My project pages had the exact same pattern, so they got the same fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: zero Storyblok calls in the browser, on first load and on internal navigation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: each article shipped 217 KB of JSON
&lt;/h2&gt;

&lt;p&gt;While I was in there, I looked at the payload itself. Each article page weighed &lt;strong&gt;217 KB&lt;/strong&gt;. The "related articles" block was fed by a list of 24 articles, with their full rich-text bodies, just to render three cards.&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="c1"&gt;// composables/useArticlesWithTranslations.ts&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withoutContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Article&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;articles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stories&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;mapStoryblokArticle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// reading time is computed here, from the body&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;withoutContent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// cards only need title, excerpt, cover, tags and date&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An article payload went from &lt;strong&gt;217 KB to 32 KB&lt;/strong&gt;, and the blog index from &lt;strong&gt;112 KB to 12 KB&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check your own Nuxt site
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open a prerendered page with DevTools, Network tab, filtered on your CMS or API host. On a static page, it should stay empty.&lt;/li&gt;
&lt;li&gt;Or paste this in the console:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;performance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getEntriesByType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;resource&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;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;api.storyblok.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// your CMS or API host&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;entry&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Look for top-level &lt;code&gt;await&lt;/code&gt; calls in your pages that aren't wrapped in &lt;code&gt;useAsyncData&lt;/code&gt; or &lt;code&gt;useFetch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check the size of &lt;code&gt;/&amp;lt;route&amp;gt;/_payload.json&lt;/code&gt;: it's downloaded and parsed on every visit.&lt;/li&gt;
&lt;li&gt;In Search Console, "Test live URL" then "View tested page" shows what Googlebot actually rendered.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In a prerendered Nuxt app, a bare &lt;code&gt;await&lt;/code&gt; in &lt;code&gt;&amp;lt;script setup&amp;gt;&lt;/code&gt; runs twice: at build time and in the browser.&lt;/li&gt;
&lt;li&gt;Wrap data loading in &lt;code&gt;useAsyncData&lt;/code&gt; or &lt;code&gt;useFetch&lt;/code&gt; so the payload feeds hydration.&lt;/li&gt;
&lt;li&gt;Don't throw a fatal error from code that can run in the browser for data you already prerendered.&lt;/li&gt;
&lt;li&gt;Keep list payloads lean: cards don't need article bodies.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've asked Google to recrawl the three posts. I'll update this article with what Search Console says.&lt;/p&gt;




&lt;p&gt;I'm a freelance developer in Rennes, France, building custom business tools and websites for small businesses. The site this happened on, and more write-ups like this one: &lt;a href="https://dibodev.fr" rel="noopener noreferrer"&gt;dibodev.fr&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nuxt</category>
      <category>vue</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>First-touch attribution on a cookieless static Nuxt site</title>
      <dc:creator>Léo Guillaume (Dibodev)</dc:creator>
      <pubDate>Mon, 21 Sep 2026 00:51:39 +0000</pubDate>
      <link>https://dev.to/dibodev/first-touch-attribution-on-a-cookieless-static-nuxt-site-4f16</link>
      <guid>https://dev.to/dibodev/first-touch-attribution-on-a-cookieless-static-nuxt-site-4f16</guid>
      <description>&lt;p&gt;I run a small static Nuxt site (SSG + Storyblok) with cookieless analytics: no consent banner, nothing stored server-side about who visits. Great for privacy, but it left me blind on the one question every freelancer or small business asks: where does each lead actually come from?&lt;/p&gt;

&lt;p&gt;When someone fills my contact form, I want to know: Google organic? A referral from another site? A specific campaign? Cookieless tools (PostHog in memory mode, Umami) give me aggregate referrers, but they cannot easily tell me which source &lt;em&gt;this specific contact&lt;/em&gt; came from. So I added a tiny first-touch attribution layer (no cookies, no library) that tags each contact with where the visitor first landed.&lt;/p&gt;

&lt;p&gt;Here is the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Capture first-touch once, in localStorage
&lt;/h2&gt;

&lt;p&gt;First-touch means the very first time someone lands on the site, I record the external &lt;code&gt;document.referrer&lt;/code&gt;, the landing path, and the UTM params. I store it once, so a visitor who found me via Google last week and returns direct today is still attributed to Google: real first-touch, not last-click.&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="c1"&gt;// app/composables/useLeadSource.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lead_source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useLeadSource&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;captureLeadSourceOnce&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="k"&gt;void&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;try&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="c1"&gt;// already captured&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;search&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;referrer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isExternal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;referrer&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="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isExternal&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;landingPage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;utmSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_source&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;utmMedium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="na"&gt;utmCampaign&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;params&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utm_campaign&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// localStorage throws in private mode or blocked storage: never crash a page over analytics&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLeadSource&lt;/span&gt;&lt;span class="p"&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="k"&gt;typeof&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;undefined&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="k"&gt;try&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;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;captureLeadSourceOnce&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getLeadSource&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;Two details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The isExternal check.&lt;/strong&gt; &lt;code&gt;document.referrer&lt;/code&gt; on an internal navigation is your own domain. Keep it only when it is a real external referrer; otherwise it is null (direct).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;try/catch everywhere.&lt;/strong&gt; &lt;code&gt;localStorage&lt;/code&gt; throws in private mode or when storage is blocked. Analytics should never take down a page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fire it once, client-side
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/plugins/lead-source.client.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useLeadSource&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;~/composables/useLeadSource&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineNuxtPlugin&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useLeadSource&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;captureLeadSourceOnce&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;It is a &lt;code&gt;.client.ts&lt;/code&gt; plugin because &lt;code&gt;localStorage&lt;/code&gt; and &lt;code&gt;document.referrer&lt;/code&gt; do not exist during SSG prerender. The guard inside the composable makes it idempotent, so SPA navigations and reloads never overwrite the first touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attach it to the contact form
&lt;/h2&gt;

&lt;p&gt;On submit (and even on a contact intent, the first time they blur the email field before they finish), read the stored source and send it with the payload:&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getLeadSource&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useLeadSource&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;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...name, email, message...&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;getLeadSource&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// { referrer, landingPage, utmSource, ... } or null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Server-side, turn it into a one-line label for the notification email:&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;function&lt;/span&gt; &lt;span class="nf"&gt;formatAcquisitionSource&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utmSource&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;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utmMedium&lt;/span&gt; &lt;span class="p"&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utmSource&lt;/span&gt;&lt;span class="p"&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utmMedium&lt;/span&gt;&lt;span class="p"&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utmSource&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utmCampaign&lt;/span&gt; &lt;span class="p"&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;base&lt;/span&gt;&lt;span class="p"&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utmCampaign&lt;/span&gt;&lt;span class="p"&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;base&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;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Direct / unknown&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;Now every contact email arrives with &lt;code&gt;Acquisition source: google.com&lt;/code&gt; and the landing page, so I know where each lead came from, per lead, without a single cookie.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just read PostHog or GA?
&lt;/h2&gt;

&lt;p&gt;Cookieless analytics already capture &lt;code&gt;$referrer&lt;/code&gt; and UTM on events, which is perfect for aggregate dashboards. Two gaps this fills:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-lead.&lt;/strong&gt; Correlating one specific submission to a source in the analytics UI is fiddly. Having it in the email is instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-touch persistence.&lt;/strong&gt; With in-memory or cookieless persistence, the tool often cannot stitch a returning visitor back to their original source. localStorage does that cheaply for the one thing I care about: leads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Around 40 lines, zero dependencies, no cookie, no consent banner. For a small business or freelance site, that is the attribution problem solved well enough.&lt;/p&gt;




&lt;p&gt;I build these small, boring-but-useful things for small businesses as a freelance dev. More of them (and the site this runs on): &lt;a href="https://dibodev.fr" rel="noopener noreferrer"&gt;dibodev.fr&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nuxt</category>
      <category>webdev</category>
      <category>analytics</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Auto-translating a static Nuxt blog into FR/EN/ES: hreflang, sitemap, and the duplicate-canonical trap</title>
      <dc:creator>Léo Guillaume (Dibodev)</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:14:24 +0000</pubDate>
      <link>https://dev.to/dibodev/auto-translating-a-static-nuxt-blog-into-frenes-hreflang-sitemap-and-the-duplicate-canonical-4oel</link>
      <guid>https://dev.to/dibodev/auto-translating-a-static-nuxt-blog-into-frenes-hreflang-sitemap-and-the-duplicate-canonical-4oel</guid>
      <description>&lt;p&gt;I run my freelance site as a fully static Nuxt (SSG) blog. Content is authored in &lt;strong&gt;French&lt;/strong&gt; in a headless CMS, and I wanted &lt;strong&gt;English and Spanish&lt;/strong&gt; versions — without hand-translating every post, and without maintaining three copies in the CMS.&lt;/p&gt;

&lt;p&gt;Here's the setup I landed on, and the one SEO gotcha nobody warns you about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint: static, one source of truth
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Nuxt in SSG mode (everything prerendered to static files).&lt;/li&gt;
&lt;li&gt;Articles authored in French only, in the CMS.&lt;/li&gt;
&lt;li&gt;I didn't want a second/third CMS locale to keep in sync. So: &lt;strong&gt;French is the single source of truth; EN/ES are generated from it.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The translation pipeline
&lt;/h2&gt;

&lt;p&gt;A small admin page has a single "Translate EN + ES" button. When I press it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It sends the French article to an LLM.&lt;/li&gt;
&lt;li&gt;It gets back the translated &lt;code&gt;title&lt;/code&gt;, &lt;code&gt;excerpt&lt;/code&gt;, &lt;code&gt;metaTitle&lt;/code&gt;, &lt;code&gt;metaDescription&lt;/code&gt;, &lt;code&gt;tags&lt;/code&gt; and rich-text &lt;code&gt;content&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It writes them to JSON files committed to the repo, &lt;strong&gt;keyed by the French slug&lt;/strong&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;content/translations/articles.es.json&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"blog/mon-article-en-francais"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mi artículo en español"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"excerpt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"metaTitle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"metaDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"…"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"doc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;rich&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;text&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the site is static, saving a translation pushes to GitHub, which triggers a rebuild. No runtime translation, no per-request cost, nothing to break in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering: overlay the translation at build time
&lt;/h2&gt;

&lt;p&gt;The article page fetches the French story from the CMS, then &lt;strong&gt;overlays&lt;/strong&gt; the JSON translation when the current locale is &lt;code&gt;en&lt;/code&gt; or &lt;code&gt;es&lt;/code&gt;:&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;let&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapStory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;storyResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;story&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// FR from CMS&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;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&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="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;translations&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/api/translations/articles/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;translations&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;`blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt; &lt;span class="o"&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;article&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// title, excerpt, content, meta, tags&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One template, one data path, three languages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Routing: prefix_except_default
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;@nuxtjs/i18n&lt;/code&gt; and &lt;code&gt;strategy: 'prefix_except_default'&lt;/code&gt;, the default locale stays clean and the others get a prefix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FR → &lt;code&gt;/blog/my-slug&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;EN → &lt;code&gt;/en/blog/my-slug&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;ES → &lt;code&gt;/es/blog/my-slug&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One deliberate choice: for &lt;strong&gt;articles I keep the same slug across locales&lt;/strong&gt; — only the content is translated. My &lt;em&gt;category&lt;/em&gt; pages do translate the slug (via &lt;code&gt;customRoutes: 'meta'&lt;/code&gt; and a per-locale slug map), but for blog posts a shared slug is simpler and works fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  SEO plumbing: self-canonical + hreflang + sitemap
&lt;/h2&gt;

&lt;p&gt;Each locale canonicalises to &lt;strong&gt;itself&lt;/strong&gt;, not to the French version:&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="nf"&gt;useHead&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;rel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canonical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&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;site&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nf"&gt;localePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&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="s2"&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;A global composable emits the full reciprocal &lt;code&gt;hreflang&lt;/code&gt; cluster (&lt;code&gt;fr-FR&lt;/code&gt;, &lt;code&gt;en-US&lt;/code&gt;, &lt;code&gt;es-ES&lt;/code&gt;, plus &lt;code&gt;x-default&lt;/code&gt; → FR), and the sitemap is an index with one sub-sitemap per locale, each entry carrying &lt;code&gt;&amp;lt;xhtml:link rel="alternate" hreflang="…"&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I checked the rendered &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; on a live ES page and it was textbook-correct: &lt;code&gt;lang="es"&lt;/code&gt;, self-canonical to the &lt;code&gt;/es/&lt;/code&gt; URL, and all four hreflang links present.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha nobody warns you about
&lt;/h2&gt;

&lt;p&gt;Here's the part I wish I'd known earlier. Even with a correct self-canonical, a complete reciprocal hreflang cluster, and every URL in the sitemap with hreflang, Google Search Console still flagged a bunch of my ES/EN pages as:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Duplicate, Google chose a different canonical than the user.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In other words, Google &lt;strong&gt;overrode my (correct) canonical&lt;/strong&gt; and consolidated the translated page into the French original.&lt;/p&gt;

&lt;p&gt;Why does this happen? On &lt;strong&gt;low-authority sites&lt;/strong&gt; with &lt;strong&gt;machine-translated content&lt;/strong&gt;, Google often decides the translation doesn't add enough unique value and folds it into the source URL — regardless of what your canonical says. Your canonical is a &lt;em&gt;hint&lt;/em&gt;, not a command.&lt;/p&gt;

&lt;p&gt;What actually moves the needle (and what doesn't):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;❌ &lt;strong&gt;A translated slug doesn't fix it.&lt;/strong&gt; Google consolidates on content and signals, not on the URL string.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Real signals do:&lt;/strong&gt; internal links pointing to the &lt;code&gt;/en/&lt;/code&gt; and &lt;code&gt;/es/&lt;/code&gt; versions, overall domain authority, and translations that are genuinely useful rather than thin.&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Time.&lt;/strong&gt; It's frequently transient and clears as Google recrawls and trusts the domain more.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; technically-perfect hreflang and canonical are &lt;em&gt;necessary but not sufficient&lt;/em&gt;. On a young, low-authority multilingual site, Google will keep consolidating your translated pages until you earn its trust — and "Duplicate, Google chose a different canonical" is a &lt;strong&gt;neutral&lt;/strong&gt; status (your original is still indexed), not a penalty.&lt;/p&gt;




&lt;p&gt;This setup powers the blog on my site, &lt;a href="https://dibodev.fr" rel="noopener noreferrer"&gt;dibodev.fr&lt;/a&gt; — I'm a freelance web developer near Rennes (France) building custom business tools, SaaS and internal apps for small companies. Happy to dig into any part of the pipeline in the comments.&lt;/p&gt;

</description>
      <category>nuxt</category>
      <category>seo</category>
      <category>i18n</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Vue app, three homes: desktop with Tauri, Android with Capacitor, and the web</title>
      <dc:creator>Léo Guillaume (Dibodev)</dc:creator>
      <pubDate>Tue, 18 Aug 2026 23:23:27 +0000</pubDate>
      <link>https://dev.to/dibodev/one-vue-app-three-homes-desktop-with-tauri-android-with-capacitor-and-the-web-4eoe</link>
      <guid>https://dev.to/dibodev/one-vue-app-three-homes-desktop-with-tauri-android-with-capacitor-and-the-web-4eoe</guid>
      <description>&lt;p&gt;I wanted a Reddit client that lived on my desktop &lt;em&gt;and&lt;/em&gt; my phone — without maintaining two separate apps. So I built one Vue app and gave it three homes: &lt;strong&gt;Tauri&lt;/strong&gt; (Rust) wraps it as a desktop app for Windows/macOS/Linux, &lt;strong&gt;Capacitor&lt;/strong&gt; wraps it for Android, and it still runs as a plain web app. One UI, one set of Reddit API calls, one OAuth flow — three targets.&lt;/p&gt;

&lt;p&gt;It works. But "just wrap your web app" hides a few things. Here's the reality.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A single &lt;strong&gt;Vue.js&lt;/strong&gt; frontend: the whole UI, the Reddit API calls, the OAuth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tauri&lt;/strong&gt; for desktop — the webview is the OS's native one, the backend is Rust.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacitor&lt;/strong&gt; for Android — desktop got Tauri, Android got Capacitor. Use the wrapper that's mature for each target instead of forcing one tool everywhere.&lt;/li&gt;
&lt;li&gt;Plain &lt;strong&gt;web&lt;/strong&gt; for anyone who just wants a URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gotcha 1: you can't cross-compile
&lt;/h2&gt;

&lt;p&gt;You cannot build a Windows &lt;code&gt;.msi&lt;/code&gt; on your Mac. Tauri leans on each platform's native toolchain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.msi&lt;/code&gt; / &lt;code&gt;.exe&lt;/code&gt; → built on Windows&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.dmg&lt;/code&gt; / &lt;code&gt;.app&lt;/code&gt; → built on macOS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.deb&lt;/code&gt; / &lt;code&gt;.AppImage&lt;/code&gt; → built on Linux&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Ship for all platforms" really means a CI matrix with one runner per OS. Plan for it early — it shapes your whole release pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: WebView2 on older Windows
&lt;/h2&gt;

&lt;p&gt;Tauri doesn't ship a browser engine; it uses the system webview. Windows 11 has WebView2; Windows 7–10 might not — and a missing webview means users open the app to a blank white window. Bundle the WebView2 bootstrapper in your installer so it self-installs. Tiny detail, terrible first impression if you miss it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: OAuth redirect is the fiddly part
&lt;/h2&gt;

&lt;p&gt;In a browser, OAuth is easy: Reddit redirects to your callback URL, you read the code, done. In a &lt;em&gt;packaged&lt;/em&gt; app there's no web server at that URL. So you register a custom scheme / deep link, let the OS hand the redirect back to the app, and pull the &lt;code&gt;code&lt;/code&gt; out of the incoming URL — on desktop and Android, each with its own quirks. Easily the most time-consuming part, and the bit tutorials skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 4: a default Rust release binary is chunky
&lt;/h2&gt;

&lt;p&gt;A handful of &lt;code&gt;Cargo.toml&lt;/code&gt; flags make a real dent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[profile.release]&lt;/span&gt;
&lt;span class="py"&gt;panic&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"abort"&lt;/span&gt;      &lt;span class="c"&gt;# drop the unwinding machinery&lt;/span&gt;
&lt;span class="py"&gt;codegen-units&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;    &lt;span class="c"&gt;# compile as one unit so the optimizer can do more&lt;/span&gt;
&lt;span class="py"&gt;lto&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;           &lt;span class="c"&gt;# link-time optimization&lt;/span&gt;
&lt;span class="py"&gt;opt-level&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"s"&lt;/span&gt;      &lt;span class="c"&gt;# optimize for size&lt;/span&gt;
&lt;span class="py"&gt;strip&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;         &lt;span class="c"&gt;# strip symbols from the binary&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Free size win, no code changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;Yes. One Vue codebase, native apps on desktop and Android, plus a web version — without three projects to keep in sync. If you're a web dev eyeing desktop/mobile, Vue + Tauri + Capacitor is a very reasonable way in — just budget time for the packaging and OAuth reality, not the happy-path demo.&lt;/p&gt;

&lt;p&gt;I do this kind of cross-platform / custom-tooling work as a freelance dev at &lt;a href="https://dibodev.fr" rel="noopener noreferrer"&gt;dibodev.fr&lt;/a&gt;. Happy to get into the OAuth deep-linking or the CI matrix in the comments.&lt;/p&gt;

</description>
      <category>tauri</category>
      <category>rust</category>
      <category>vue</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My static Nuxt blog publishes itself: drafts, scheduled "drip", and auto-rebuilds</title>
      <dc:creator>Léo Guillaume (Dibodev)</dc:creator>
      <pubDate>Tue, 18 Aug 2026 22:45:55 +0000</pubDate>
      <link>https://dev.to/dibodev/my-static-nuxt-blog-publishes-itself-drafts-scheduled-drip-and-auto-rebuilds-4hmi</link>
      <guid>https://dev.to/dibodev/my-static-nuxt-blog-publishes-itself-drafts-scheduled-drip-and-auto-rebuilds-4hmi</guid>
      <description>&lt;p&gt;I run my freelance site as a fully static (SSG) Nuxt site. It's fast, cheap to host, and boring in the good way. But static sites are genuinely bad at one thing: publishing an article next Tuesday at 9am. There's no server rendering each request, so "schedule this post" doesn't exist — something has to rebuild the site at the right moment.&lt;/p&gt;

&lt;p&gt;I also write in bursts. Some evenings I'll draft two or three articles at once, and I don't want them all going live the same day. So I bolted a small publishing engine onto my existing stack: drafts, a scheduled queue (a "drip"), and automatic rebuilds. Here's how the pieces fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Nuxt 4, prerendered (SSG) for every public page&lt;/li&gt;
&lt;li&gt;Storyblok as the headless CMS — the source of truth for content&lt;/li&gt;
&lt;li&gt;A Nitro server (the same Nuxt app) on a small VPS under PM2 — the always-on part&lt;/li&gt;
&lt;li&gt;GitHub Actions for the deploy/rebuild&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing rests on one realization: on an SSG site, "publish" is two actions, not one — (1) put the content in the CMS, and (2) rebuild the static site so it actually shows up. Scheduling is just doing both later, automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Drafts: write now, decide later
&lt;/h2&gt;

&lt;p&gt;The dashboard has a markdown editor. Drafts are saved server-side (Nitro's storage layer), each with a status (draft / scheduled / published) and a publish date. Nothing touches the live site yet — a draft is just a row waiting for its moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The queue and the "drip"
&lt;/h2&gt;

&lt;p&gt;The fun part. A Nitro scheduled task runs every hour and asks one question: &lt;em&gt;is anything due?&lt;/em&gt;&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="c1"&gt;// nuxt.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineNuxtConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;nitro&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;experimental&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tasks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;scheduledTasks&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;5 * * * *&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;articles:process-queue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="c1"&gt;// every hour, at :05&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// server/tasks/articles/process-queue.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineTask&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&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:process-queue&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;run&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;due&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getDueScheduledArticles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// publishDate &amp;lt;= now&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;due&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nothing due&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;for &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;article&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publishToStoryblok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// markdown -&amp;gt; richtext -&amp;gt; CMS&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;triggerSiteRebuild&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// ONE rebuild for the whole batch&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`published &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;due&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;That's the entire scheduler. No queue service, no cron box, no extra infra — just a task the Nitro server already knows how to run.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Markdown → CMS → rebuild
&lt;/h2&gt;

&lt;p&gt;When a post is due, it's converted from markdown to Storyblok's richtext format, pushed to the CMS, and then — the non-negotiable step on a static site — the site is rebuilt. No rebuild, no article. I trigger it with a &lt;code&gt;workflow_dispatch&lt;/code&gt; call to my deploy workflow:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;triggerSiteRebuild&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;$fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.github.com/repos/&amp;lt;owner&amp;gt;/&amp;lt;repo&amp;gt;/actions/workflows/deploy.yml/dispatches&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="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;REPO_ACCESS_TOKEN&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// needs `actions: write`&lt;/span&gt;
        &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/vnd.github+json&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="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ref&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;main&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="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Three gotchas I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There's still a server.&lt;/strong&gt; The scheduled task only fires if something is actually running. It's easy to forget an SSG site has no always-on backend by default — the Nitro server under PM2 exists purely so the scheduler (and the admin) stay alive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One rebuild per batch, not per article.&lt;/strong&gt; If three posts are due in the same run, publish all three &lt;em&gt;then&lt;/em&gt; rebuild once. Otherwise you kick off three deploys back to back for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timezones.&lt;/strong&gt; "9am" means nothing until you pin the timezone. Ask me how I know.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The payoff
&lt;/h2&gt;

&lt;p&gt;Now I can draft on a Sunday, spread posts across the next two weeks, and close the laptop. They go live on their own, the site rebuilds itself, and I stop babysitting a "publish" button.&lt;/p&gt;

&lt;p&gt;It powers the blog on my freelance site, &lt;a href="https://dibodev.fr" rel="noopener noreferrer"&gt;dibodev.fr&lt;/a&gt; — building this kind of custom tooling (and business apps / SaaS) for small companies is basically my day job.&lt;/p&gt;

&lt;p&gt;Happy to dig into the Nitro task or the rebuild trigger in the comments.&lt;/p&gt;

</description>
      <category>nuxt</category>
      <category>vue</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
