Localize a Nuxt 3 app: complete guide with Localingos
Nuxt 3 with @nuxtjs/i18n is the cleanest server-rendered Vue i18n stack available — locale routing, SEO meta generation, and hreflang annotations all out of the box. This guide combines it with Localingos for the translation pipeline. About 15 minutes start to finish.
Step 1 — Install
npm install @nuxtjs/i18n@^10
npm install -g localingos
This guide targets @nuxtjs/i18n v9/v10. Several option names changed in v9, so a v7/v8 config will silently misbehave — the notes below flag each one.
Step 2 — Configure @nuxtjs/i18n
nuxt.config.ts:
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
defaultLocale: 'en-US',
strategy: 'prefix_except_default', // English at /, others at /es-ES/, /de-DE/...
locales: [
{ code: 'en-US', language: 'en-US', name: 'English', file: 'en-US.json' },
{ code: 'es-ES', language: 'es-ES', name: 'Español', file: 'es-ES.json' },
{ code: 'de-DE', language: 'de-DE', name: 'Deutsch', file: 'de-DE.json' },
{ code: 'fr-FR', language: 'fr-FR', name: 'Français', file: 'fr-FR.json' },
{ code: 'ja-JP', language: 'ja-JP', name: '日本語', file: 'ja-JP.json' },
],
langDir: 'locales/',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_locale',
fallbackLocale: 'en-US',
redirectOn: 'root', // only auto-redirect on /, not deep links (SEO-safe)
},
},
});
Four things to understand here:
langDiris resolved relative to the i18n directory, not the project root. In v9+ that base is<rootDir>/i18n, so the correct value for files ati18n/locales/is'locales/'. Writing'i18n/locales/'doubles the segment and the build fails withENOENT … /i18n/i18n/locales/en-US.json. Note that yourlocalingos.config.jsondoes use the repo-relativei18n/locales— the two paths look similar and mean different things.- Use
language, notiso.isowas renamed in v9. Because the locale object accepts arbitrary keys, anisofield passes type-checking and builds cleanly — it is simply ignored, which silently disables the hreflang and SEO output in Step 7 as well as browser-language matching. This one produces no error at all, so it's worth double-checking. - There is no
lazyoption any more. Lazy loading is unconditional in v10; passinglazy: truefailsnuxi typecheckwith'lazy' does not exist in type NuxtI18nOptions. redirectOn: 'root'— auto-redirects first-time visitors only on the homepage, not on deep links. Critical: deep-link redirects break crawlers and SEO.
Use the same locale codes Localingos uses — full BCP 47, so code: 'es-ES' and file: 'es-ES.json'. The CLI names each file after the project's target-locale code, so matching them means no translation layer between the two.
Set a message fallback
detectBrowserLanguage.fallbackLocale governs detection, not message resolution. To stop a missing key rendering as its raw path, set a fallback for messages too, in i18n/i18n.config.ts:
export default defineI18nConfig(() => ({
legacy: false,
fallbackLocale: 'en-US',
}));
Step 3 — Source of truth
i18n/locales/en-US.json:
{
"welcome": "Welcome, {name}",
"cart": "no items in cart | one item in cart | {count} items in cart"
}
Vue's | pluralization syntax.
Step 4 — Configure Localingos
Run localingos init and answer its prompts. It writes two files.
localingos.config.json — commit this. Project settings shared with your team and CI:
{
"projectId": "your-project-id",
"sourceLocale": "en-US",
"format": "json-nested",
"sourceFile": "i18n/locales/en-US.json",
"outputDir": "i18n/locales",
"outputPattern": "{locale}.json"
}
.localingos.json — add this to .gitignore. Your API key for local development:
{ "apiKey": "your-api-key" }
In CI, set LOCALINGOS_API_KEY instead; it takes precedence over both files. init is interactive, so for containers, provisioning scripts or AI agents, write these two files yourself — the CLI reads nothing else.
Three things worth knowing:
formatdecides your key shape.json-nestedmapshome.titleto{ "home": { "title": … } };json-flatkeeps"home.title"as one top-level key. Those are the two supported values.- Target locales are not configured here. They belong to the project itself — set them in the dashboard under Projects → Edit → Locales → Update Project. The CLI writes one file per target locale the project has, so you add a language without touching your repo. Codes are full BCP 47, e.g.
es-ES,pt-BR,zh-TW. - Placeholder preservation is automatic. Localingos extracts placeholders from the source string —
{{name}},{name},${count},%s,%d, ICU fragments — and validates each one survives translation, retrying with a corrective prompt when it doesn't. There is nothing to configure.
Then push your source strings and pull back translations:
localingos sync
Translation is asynchronous
The first sync of a new key pushes it and usually has nothing to pull back yet:
Push: 10 created, 0 updated, 0 deleted, 0 unchanged
✅ 0 translations received
⏳ 10 keys pending translation: home.title, home.subtitle, …
No new translations. Run "localingos sync" again later.
That's expected. Run localingos sync (or localingos pull) again shortly to collect results. Unchanged strings are never re-translated or re-billed.
Verify completeness before committing. pull writes whatever is ready and exits 0, so a file pulled mid-translation can be missing keys with no warning and no error — at runtime that surfaces as a silent fallback to your source language. Check key counts per locale before you commit, and gate on it in CI.
Step 5 — Use in components
<script setup lang="ts">
const { t } = useI18n();
defineProps<{ userName: string; itemCount: number }>();
</script>
<template>
<header>
<h1>{{ t('welcome', { name: userName }) }}</h1>
<p>{{ t('cart', itemCount) }}</p>
</header>
</template>
Step 6 — Locale-aware links
Use Nuxt's built-in helper to generate URLs in the active locale:
<template>
<NuxtLinkLocale to="/pricing">{{ t('nav.pricing') }}</NuxtLinkLocale>
</template>
When the active locale is es, this generates /es/pricing. When it's en, /pricing. Saves manual URL construction.
Step 7 — SEO meta and hreflang
Nuxt i18n generates hreflang automatically when you use useLocaleHead:
<script setup lang="ts">
const head = useLocaleHead({ seo: true, lang: true, dir: true });
useHead(head);
</script>
This injects per-page <link rel="alternate" hreflang="..."> for every configured locale and a <link rel="canonical">. Google sees the right localized variant and treats /pricing as canonical with /es/pricing as an alternate.
Step 8 — Language switcher
<script setup lang="ts">
const { locale, locales, setLocale } = useI18n();
const switchLocaleTo = (newLocale: string) => setLocale(newLocale);
</script>
<template>
<select :value="locale" @change="e => switchLocaleTo((e.target as HTMLSelectElement).value)">
<option v-for="l in locales" :key="l.code" :value="l.code">{{ l.code.toUpperCase() }}</option>
</select>
</template>
setLocale() updates the URL to the locale-prefixed path automatically — /pricing → /es/pricing. Plays nicely with browser back/forward.
Step 9 — Automate sync in CI
# .github/workflows/i18n.yml
name: i18n-sync
on:
push: { branches: [main], paths: ['i18n/locales/en-US.json'] }
jobs:
sync:
runs-on: ubuntu-latest
permissions: { contents: write, pull-requests: write }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm install -g localingos
- run: localingos sync
env: { LOCALINGOS_API_KEY: '${{ secrets.LOCALINGOS_API_KEY }}' }
- uses: peter-evans/create-pull-request@v6
with:
branch: i18n/auto-sync
title: 'chore(i18n): sync translations'
commit-message: 'chore(i18n): sync translations'
Production checklist
- Static generation (
nuxt generate). Each locale gets its own pre-rendered HTML — no extra work needed. - Server-side rendering. Locale detected from cookie/header before render; no hydration mismatch.
- Sitemap. Use
@nuxtjs/sitemapalongside i18n — it auto-generates per-locale entries with hreflang. - RTL.
useLocaleHead({ dir: true })emits thedirattribute. (addSeoAttributesandaddDirAttributeare the pre-v9 names and are ignored if you still pass them.)
Wrap up
A Nuxt 3 app with locale routing, SEO meta, hreflang, and a CI translation pipeline — all in about 15 minutes. The prefix_except_default strategy plus auto-generated alternates is the most SEO-friendly setup available for a Vue stack.
Free tier: 5,000 words, counted once per target locale — so a small Nuxt site in three or four languages, not a whole corpus in all 56. Which plan do I need? works it out for your own string count.