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:
- It sends the French article to an LLM.
- It gets back the translated
title,excerpt,metaTitle,metaDescription,tagsand rich-textcontent. - 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.