DEV Community

Léo Guillaume (Dibodev)
Léo Guillaume (Dibodev)

Posted on

Auto-translating a static Nuxt blog into FR/EN/ES: hreflang, sitemap, and the duplicate-canonical trap

I run my freelance site as a fully static Nuxt (SSG) blog. Content is authored in French in a headless CMS, and I wanted English and Spanish versions — without hand-translating every post, and without maintaining three copies in the CMS.

Here's the setup I landed on, and the one SEO gotcha nobody warns you about.

The constraint: static, one source of truth

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

The translation pipeline

A small admin page has a single "Translate EN + ES" button. When I press it:

  1. It sends the French article to an LLM.
  2. It gets back the translated title, excerpt, metaTitle, metaDescription, tags and rich-text content.
  3. It writes them to JSON files committed to the repo, keyed by the French slug:
// content/translations/articles.es.json
{
  "blog/mon-article-en-francais": {
    "title": "Mi artículo en español",
    "excerpt": "…",
    "metaTitle": "…",
    "metaDescription": "…",
    "tags": ["…"],
    "content": { "type": "doc", "content": [ /* rich text */ ] }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

Rendering: overlay the translation at build time

The article page fetches the French story from the CMS, then overlays the JSON translation when the current locale is en or es:

let article = mapStory(storyResponse.story) // FR from CMS

if (locale.value === 'en' || locale.value === 'es') {
  const translations = await $fetch(`/api/translations/articles/${locale.value}`)
  const t = translations[`blog/${slug}`]
  if (t) article = { ...article, ...t } // title, excerpt, content, meta, tags
}
Enter fullscreen mode Exit fullscreen mode

One template, one data path, three languages.

Routing: prefix_except_default

With @nuxtjs/i18n and strategy: 'prefix_except_default', the default locale stays clean and the others get a prefix:

  • FR → /blog/my-slug
  • EN → /en/blog/my-slug
  • ES → /es/blog/my-slug

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

SEO plumbing: self-canonical + hreflang + sitemap

Each locale canonicalises to itself, not to the French version:

useHead(() => ({
  link: [{ rel: 'canonical', href: `${site}${localePath(article.route)}` }],
}))
Enter fullscreen mode Exit fullscreen mode

A global composable emits the full reciprocal hreflang cluster (fr-FR, en-US, es-ES, plus x-default → FR), and the sitemap is an index with one sub-sitemap per locale, each entry carrying <xhtml:link rel="alternate" hreflang="…">.

I checked the rendered <head> on a live ES page and it was textbook-correct: lang="es", self-canonical to the /es/ URL, and all four hreflang links present.

The gotcha nobody warns you about

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:

Duplicate, Google chose a different canonical than the user.

In other words, Google overrode my (correct) canonical and consolidated the translated page into the French original.

Why does this happen? On low-authority sites with machine-translated content, 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 hint, not a command.

What actually moves the needle (and what doesn't):

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

Takeaway: technically-perfect hreflang and canonical are necessary but not sufficient. 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 neutral status (your original is still indexed), not a penalty.


This setup powers the blog on my site, dibodev.fr — 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.

Top comments (0)