Skip to content

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:

  • langDir is resolved relative to the i18n directory, not the project root. In v9+ that base is <rootDir>/i18n, so the correct value for files at i18n/locales/ is 'locales/'. Writing 'i18n/locales/' doubles the segment and the build fails with ENOENT … /i18n/i18n/locales/en-US.json. Note that your localingos.config.json does use the repo-relative i18n/locales — the two paths look similar and mean different things.
  • Use language, not iso. iso was renamed in v9. Because the locale object accepts arbitrary keys, an iso field 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 lazy option any more. Lazy loading is unconditional in v10; passing lazy: true fails nuxi typecheck with '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:

  • format decides your key shape. json-nested maps home.title to { "home": { "title": … } }; json-flat keeps "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 ProjectsEditLocalesUpdate 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/sitemap alongside i18n — it auto-generates per-locale entries with hreflang.
  • RTL. useLocaleHead({ dir: true }) emits the dir attribute. (addSeoAttributes and addDirAttribute are 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.