<?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>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>
