Internationaliser une app Nuxt 4 avec @nuxtjs/i18n et Locize
Nuxt est le framework full-stack dominant pour Vue : routage basé sur les fichiers, Nitro côté serveur, Vite sous le capot et un écosystème de modules à l'opinion affirmée. Pour l'internationalisation, le choix canonique est @nuxtjs/i18n, qui enveloppe vue-i18n avec un routage qui connaît Nuxt, des helpers SEO et un middleware de détection de la locale.
Cet article décrit comment associer cette stack à Locize comme backend de gestion des traductions : comment câbler @nuxtjs/i18n avec la nouvelle implémentation vue-i18n du package locize (introduite en 4.1.0), utiliser locize-cli pour la synchronisation au build, optionnellement pousser les nouvelles clés vers Locize à l'exécution via le hook missing de vue-i18n, et éditer les traductions sur place via l'éditeur InContext de Locize.
Si vous êtes sur React Router v7 (mode Framework) à la place, suivez le walkthrough React Router v7. Si vous êtes encore sur Remix v2, voir l'article Remix v2.
Si vous préférez utiliser i18next directement sur Vue (sans
@nuxtjs/i18n, sans vue-i18n), voir i18next-vue pour la stack alternative.
TL;DR
- Utilisez
@nuxtjs/i18n10.x comme module i18n Nuxt. Il prend en charge le routage, le SEO, la détection de locale et le lazy-loading ; vue-i18n en dessous fournit l'APIt(). - Empaquetez les traductions dans l'app au build via la commande
downloaddelocize-cli. Le serveur (Nitro SSR) comme le client lisent le même JSON, sans saut CDN à l'exécution, compatible serverless. - Optionnellement, repoussez les nouvelles clés vers Locize à l'exécution via le hook
missingde vue-i18n (saveMissing). Ou sautez cette étape et utilisezlocize syncdepuis la CI : c'est selon votre workflow. - Éditez en contexte avec le helper
getVueI18nImplementationdu packagelocize(livré en 4.1.0). Ajoutez?incontext=trueà n'importe quelle URL. - Exemple complet fonctionnel : github.com/locize/locize-nuxt-example
Comment les pièces s'emboîtent
Nuxt 4 exécute le même arbre Vue côté serveur (Nitro SSR) et côté client (après hydratation). @nuxtjs/i18n monte vue-i18n dans les deux contextes et ajoute autour des fonctionnalités spécifiques à Nuxt : routes typées, le composable useLocalePath, la détection de la langue du navigateur, etc.
Locize est la couche de gestion des traductions. L'intégration ici est délibérément bundle-only à l'exécution :
- Au build :
locize-clitélécharge le dernier JSON publié depuis le CDN Locize dansi18n/locales/{lng}/{ns}.json. Les fichiers JSON committés sont lus par le lazy-load de@nuxtjs/i18n, à la fois côté serveur et côté client. - Push optionnel à l'exécution (
saveMissing) : quand un appelt()référence une clé absente du JSON chargé, un missing-handler envoie la clé en POST à Locize, pour que les traducteurs la voient apparaître sans étape d'extraction manuelle. - Éditeur InContext optionnel (
?incontext=true) : ouvre l'éditeur Locize comme overlay iframe ; les traducteurs peuvent modifier toute chaîne sur laquelle ils cliquent dans la page, puis enregistrer.
Un aller-retour « traduction publiée → utilisateurs la voient » nécessite donc de relancer downloadLocales et de redéployer. C'est volontaire : cela garde Nitro compatible serverless (pas de saut CDN par requête) et libère le client d'un fetch de traduction supplémentaire. Si vous préférez un fetch live basé sur CDN (publication → la vue de page suivante le récupère, sans redéploiement), le walkthrough React Router v7 couvre cette forme avec i18next-locize-backend.
C'est parti
Prérequis
Node.js 20+, npm/pnpm/yarn, et une familiarité de base avec Vue 3 et Nuxt. L'exemple ci-dessous cible Nuxt 4.4.
Layout du projet
Générez une nouvelle app Nuxt 4 :
npx nuxi@latest init my-app
cd my-appLa forme de l'arborescence pertinente après câblage de i18n + Locize :
my-app/
├── nuxt.config.ts — @nuxtjs/i18n module config + runtimeConfig.public.locize*
├── app/ — Nuxt 4's default srcDir
│ ├── app.vue — root component
│ ├── pages/
│ │ ├── index.vue
│ │ └── second.vue
│ ├── plugins/
│ │ └── locize.client.ts — populates locize runtime state + InContext editor init
│ └── utils/
│ └── locize-runtime.ts — shared mutable runtime state for the i18n config handlers
└── i18n/
├── i18n.config.ts — vue-i18n options (missing + postTranslation handlers)
└── locales/
├── en.ts — defineI18nLocale wrapper assembling en/*.json
├── de.ts — same, for de/*.json
├── en/{common,index,second}.json
└── de/{common,index,second}.jsonQuelques détails semblent inhabituels ; la section des gotchas plus bas explique chacun.
Installer les dépendances
npm install @nuxtjs/i18n locize
npm install --save-dev locize-clivue-i18n arrive transitivement avec @nuxtjs/i18n (actuellement 11.x).
Configurer @nuxtjs/i18n dans nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2026-05-20',
modules: ['@nuxtjs/i18n'],
i18n: {
defaultLocale: 'en',
strategy: 'no_prefix',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root',
},
locales: [
{ code: 'en', language: 'en-US', name: 'English', file: 'en.ts' },
{ code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.ts' },
],
lazy: true,
},
runtimeConfig: {
public: {
locizeProjectId: '<your locize project id>',
// Dev-only — leave empty in production so saveMissing no-ops.
// Override at deploy via NUXT_PUBLIC_LOCIZE_API_KEY=''.
locizeApiKey: '<your dev apiKey>',
locizeVersion: 'latest',
locizeCdnType: 'standard', // or 'pro'
},
},
})Wrappers defineI18nLocale par locale
Le lazy-load de @nuxtjs/i18n lit un fichier par locale. Locize émet un JSON par namespace par langue, et locize sync préserve cette structure. Pour chaque locale, écrivez donc un petit wrapper .ts qui importe les fichiers JSON par namespace et les assemble sous leur clé de namespace. Cela garde l'aller-retour propre et permet à t('common.headTitle') / t('index.title') de se résoudre naturellement :
// i18n/locales/en.ts
import common from './en/common.json'
import index from './en/index.json'
import second from './en/second.json'
export default defineI18nLocale(() => ({
common,
index,
second,
}))…et la même chose pour de.ts.
Pourquoi le wrapper ? vue-i18n n'a pas de namespaces de première classe (contrairement à i18next). Les messages ne sont qu'un arbre JSON imbriqué, et t('foo.bar') est une recherche profonde dans la clé. Le wrapper superpose la structure namespace par-dessus, pour garder l'aller-retour avec Locize propre.
Télécharger les traductions depuis Locize au build
Ajoutez à package.json :
{
"scripts": {
"downloadLocales": "locize download --project-id=<your-id> --ver=latest --cdn-type=standard --clean=true --path=./i18n/locales",
"syncLocales": "locize sync --project-id=<your-id> --ver=latest --cdn-type=standard --api-key=<your-write-key> --path=./i18n/locales --dry=true"
}
}Exécutez npm run downloadLocales avant npm run build (manuellement, en CI ou comme hook prebuild), pour que le JSON empaqueté soit toujours frais.
Standard vs Pro CDN. Locize propose deux infrastructures CDN (comparaison complète dans Types de CDN : Standard vs Pro) : Standard sur
api.lite.locize.app, basé sur BunnyCDN, gratuit pour des volumes mensuels de téléchargement généreux, public uniquement (par défaut pour les nouveaux projets) ; Pro surapi.locize.app, basé sur CloudFront, payant, prend en charge les téléchargements privés + un contrôle de cache personnalisé. Faites correspondre--cdn-typesur le flag de la cli avec ce sur quoi est votre projet.
La config vue-i18n — i18n/i18n.config.ts
Ce fichier est chargé par @nuxtjs/i18n au moment de l'init de vue-i18n et contribue aux options vue-i18n. Nous l'utilisons pour enregistrer les handlers missing et postTranslation, tous deux écrits pour s'exécuter sans danger pendant le SSR (ils sont no-op côté serveur) et ne s'activent côté client que lorsque le plugin ci-dessous remplit le flag d'exécution :
import { h, Text } from 'vue'
import { wrap } from 'locize'
import { locizeRuntime } from '../app/utils/locize-runtime'
const pendingMissing = new Set()
function handleMissing(locale, key) {
if (!import.meta.client) return
if (!locizeRuntime.saveMissing || !locizeRuntime.apiKey) return
const dot = key.indexOf('.')
const ns = dot >= 0 ? key.slice(0, dot) : 'common'
const actualKey = dot >= 0 ? key.slice(dot + 1) : key
const dedupe = `${locale}/${ns}/${actualKey}`
if (pendingMissing.has(dedupe)) return
pendingMissing.add(dedupe)
fetch(`${locizeRuntime.cdnHost}/missing/${locizeRuntime.projectId}/${locizeRuntime.version}/${locale}/${ns}`, {
method: 'POST',
headers: { Authorization: locizeRuntime.apiKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ [actualKey]: actualKey }),
}).finally(() => pendingMissing.delete(dedupe))
}
const MARKER_SENTINEL = ''
function handlePostTranslation(translated, key) {
if (!import.meta.client) return translated
if (!locizeRuntime.isInContext) return translated
const dot = key.indexOf('.')
const ns = dot >= 0 ? key.slice(0, dot) : 'common'
const actualKey = dot >= 0 ? key.slice(dot + 1) : key
const meta = { key: actualKey, ns }
if (typeof translated === 'string') {
try { return wrap(translated, meta) } catch (_) { return translated }
}
if (Array.isArray(translated)) {
// vue-i18n's `<i18n-t>` slot path passes us an array of Text VNodes —
// wrap the first + last text VNodes with subliminal markers, leave
// slot VNodes untouched.
const isText = (v) => v && v.__v_isVNode && typeof v.children === 'string'
const first = translated.findIndex(isText)
if (first === -1) return translated
let last = first
for (let i = translated.length - 1; i >= 0; i--) {
if (isText(translated[i])) { last = i; break }
}
let startMarker, endMarker
try {
const sample = wrap(MARKER_SENTINEL, meta)
const parts = sample.split(MARKER_SENTINEL)
if (parts.length !== 2) return translated
startMarker = parts[0]
endMarker = parts[1]
} catch (_) { return translated }
const result = translated.slice()
if (first === last) {
result[first] = h(Text, null, startMarker + result[first].children + endMarker)
} else {
result[first] = h(Text, null, startMarker + result[first].children)
result[last] = h(Text, null, result[last].children + endMarker)
}
return result
}
return translated
}
export default function () {
return {
legacy: false,
fallbackLocale: 'en',
missingWarn: false,
fallbackWarn: false,
missing: handleMissing,
postTranslation: handlePostTranslation,
}
}Le pont d'état runtime — app/utils/locize-runtime.ts
Les handlers ci-dessus ont besoin d'accéder à la config d'exécution (project id, api key, etc.), mais i18n.config.ts n'a pas de contexte Nuxt. On fait le pont avec un petit module mutable partagé :
export const locizeRuntime = {
projectId: '',
apiKey: '',
version: 'latest',
cdnHost: 'https://api.lite.locize.app',
isInContext: false,
saveMissing: false,
}Le plugin client Nuxt — app/plugins/locize.client.ts
Celui-ci remplit l'état runtime depuis runtimeConfig.public et démarre l'éditeur InContext quand ?incontext=true est défini :
import { watch } from 'vue'
import { startStandalone, getVueI18nImplementation } from 'locize'
import { locizeRuntime } from '~/utils/locize-runtime'
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const projectId = config.public.locizeProjectId as string
const apiKey = config.public.locizeApiKey as string
const version = config.public.locizeVersion as string
const cdnType = config.public.locizeCdnType as 'standard' | 'pro'
const cdnHost =
cdnType === 'pro' ? 'https://api.locize.app' : 'https://api.lite.locize.app'
const isProduction = !import.meta.dev
const isInIframe = (() => { try { return self !== top } catch { return true } })()
const showInContext =
new URLSearchParams(window.location.search).get('incontext') === 'true'
// Populate the shared runtime so the i18n.config.ts handlers can do work.
locizeRuntime.projectId = projectId
locizeRuntime.apiKey = apiKey
locizeRuntime.version = version
locizeRuntime.cdnHost = cdnHost
locizeRuntime.isInContext = isInIframe || showInContext
locizeRuntime.saveMissing = !isProduction && !!apiKey
const i18n = nuxtApp.$i18n as any
// Force a re-render so vue-i18n re-evaluates every cached t() output
// against the now-populated runtime.
const cur = i18n.locale.value
i18n.setLocaleMessage(cur, { ...(i18n.getLocaleMessage(cur) || {}) })
if (!locizeRuntime.isInContext) return
// locize 4.1.0 ships a vue-i18n implementation alongside the i18next one.
// We supply Vue's `watch` so the editor observes locale switches — locize
// itself stays free of a `vue` peer dep.
const impl = getVueI18nImplementation(i18n, {
projectId,
version,
sourceLng: 'en',
defaultNS: 'common',
ns: ['common', 'index', 'second'],
targetLngs: (i18n.availableLocales as string[]) || [],
backendName: 'locize-cli',
watch,
})
impl.triggerRerender?.()
startStandalone({ implementation: impl, show: true })
})Utiliser t() dans les pages
Usage vue-i18n standard, avec une subtilité : les pages doivent appeler useI18n({ useScope: 'global' }) pour partager le même composer qui porte les handlers missing et postTranslation. Un simple useI18n() crée un scoped composer par composant qui n'hérite pas de ces handlers.
<script setup lang="ts">
const { t, locale, locales, setLocale } = useI18n({ useScope: 'global' })
</script>
<template>
<main>
<h1>{{ t('index.title') }}</h1>
<p>
<i18n-t keypath="index.description.part1" scope="global">
<template #file>
<code>app/pages/index.vue</code>
</template>
</i18n-t>
</p>
<NuxtLink to="/second">{{ t('index.goto.second') }}</NuxtLink>
</main>
</template>Gotchas — ce qui m'a brûlé en construisant ceci
Quelques choses non évidentes à connaître.
i18n/i18n.config.ts doit se trouver dans le répertoire i18n/
@nuxtjs/i18n 10.x résout le chemin du fichier de config vue-i18n relativement à layer.i18nDir (le répertoire i18n/), pas à la racine du projet. Un i18n.config.ts à la racine n'est silencieusement pas chargé. Voir findPath(layer.i18n.vueI18n || "i18n.config", { cwd: layer.i18nDir }) dans @nuxtjs/i18n/dist/module.mjs.
Ne pas utiliser le macro defineI18nConfig(...)
Le module @nuxtjs/i18n post-traite tout fichier contenant l'identifiant du macro avec une regex gourmande (DEFINE_I18N_FN_RE). Avec les commentaires préservés par oxc-transform, un match dans le texte d'un commentaire arrache la parenthèse fermante d'appels console.log / fetch(...) non liés dans le groupe de capture de la regex et corrompt la sortie transformée. Symptôme : Expected ')' but found ';'. Un simple export default function () { ... } (sans wrapper de macro) fonctionne tout aussi bien : @nuxtjs/i18n appelle l'export par défaut au moment de l'init.
useI18n({ useScope: 'global' }) dans les pages
useI18n() de vue-i18n crée par défaut un scoped composer par composant en mode legacy: false, et les scoped composers n'héritent PAS de missing / postTranslation du composer global. Si vos pages appellent un simple useI18n(), saveMissing ne se déclenchera pas et l'éditeur InContext ne verra aucun segment. Utilisez useScope: 'global' partout.
Composer des traductions avec du texte littéral dans les templates
Ce pattern fonctionne en JSX (React rend des nœuds texte séparés) mais casse en Vue :
<!-- DON'T -->
<NuxtLink to="/second">→ {{ t('index.goto.second') }}</NuxtLink>Le compilateur de templates de Vue fusionne le littéral → et le résultat dynamique {{ t(...) }} en un seul nœud texte DOM, et le check text.startsWith(startMarker) du parser locize échoue parce que le texte commence maintenant par → au lieu du marker. (locize 4.1.0 a ajouté un cas de fallback dans le parser qui en récupère la plupart, mais la réponse la plus propre est aussi une meilleure pratique i18n : mettez la flèche dans la valeur de traduction, pour que les traducteurs puissent la réordonner en RTL.)
<!-- DO -->
<NuxtLink to="/second">{{ t('index.goto.second') }}</NuxtLink>{ "goto": { "second": "→ Go to the second page" } }L'interpolation par slot de <i18n-t> passe un tableau de VNodes à postTranslation
Le composant <i18n-t> de vue-i18n emprunte le chemin translateVNode, qui appelle postTranslation(messaged, key), mais messaged est ici un tableau de VNodes texte Vue (pas des chaînes brutes), parce que le normalize() interne de vue-i18n convertit chaque segment chaîne en createTextNode(...) avant que le hook ne s'exécute. Un simple check typeof translated === 'string' saute entièrement ce cas. Le handler ci-dessus couvre les deux formes : wrap de la chaîne entière, OU reconstruction du premier + dernier VNode texte via h(Text, null, startMarker + children) et h(Text, null, children + endMarker).
Avertissements de mismatch d'hydratation sous ?incontext=true
Le texte enveloppé par les markers subliminaux côté client ne correspond pas à la sortie non enveloppée du serveur, donc Vue logue [Vue warn] Hydration text content mismatch pour chaque chaîne traduite quand ?incontext=true est défini. L'hydratation Vue est tolérante : elle fait confiance au client, met à jour le DOM, et le parser de l'éditeur voit les chaînes enveloppées. Les warnings sont du bruit, pas un échec, et ils n'apparaissent pas dans les chargements de page normaux (sans ?incontext=true). Si vous voulez une console propre sous ?incontext=true, il faudrait wrapper aussi sur la passe serveur (cela demanderait un état SSR par requête pour le flag runtime, hors du périmètre de cet exemple).
Trois façons d'amener de nouvelles clés dans Locize
L'exemple câble saveMissing parce que c'est le plus découvrable : chaque fois qu'un développeur référence une clé qui n'existe pas, elle apparaît automatiquement dans Locize. Mais c'est optionnel, et le chemin que vous choisissez dépend de votre workflow :
- Push
saveMissingà l'exécution (le chemin que l'exemple montre par défaut). Pratique en dev. Demande de livrer une apiKey dev dans le bundle navigateur. locize sync(déjà câblé danspackage.jsoncommesyncLocales, retirez le flag--dry=true).locize-clilit vosi18n/locales/{lng}/{ns}.jsonlocaux et envoie toutes les clés pas encore présentes dans Locize. Manuel ou déclenché en CI, sans write-key dans le navigateur.- Extraction statique via
i18next-cliou similaire : scanne le code source, écrit les nouvelles clés dans le JSON local, puis synchronise comme ci-dessus.
Pour désactiver saveMissing dans cet exemple, supprimez soit le handler missing de i18n/i18n.config.ts, soit laissez simplement NUXT_PUBLIC_LOCIZE_API_KEY vide pour que le handler soit no-op à l'exécution.
Édition InContext pour les traducteurs
Le plugin locize (juste getVueI18nImplementation(...) + startStandalone({ implementation }) dans le plugin client) donne aux traducteurs un éditeur in-context : ouvrez une page de votre app en cours d'exécution avec ?incontext=true ajouté, et un éditeur basé sur iframe s'ouvre qui met en évidence chaque chaîne traduite et permet aux traducteurs de la modifier sur place.
Les enregistrements de l'éditeur arrivent dans votre projet Locize. Pour rendre ces modifications visibles aux utilisateurs finaux, vous devrez relancer npm run downloadLocales et redéployer (cet exemple est bundle-only à l'exécution. Voir TL;DR pour le raisonnement et le cross-link vers l'alternative live-CDN-fetch).
🎉 Félicitations
Une configuration Nuxt 4 + @nuxtjs/i18n + Locize fonctionnelle vous offre :
- i18n compatible SSR sans flash de contenu non traduit
- Détection de la locale par requête via le middleware de
@nuxtjs/i18n(Cookie → URL → Accept-Language → Fallback) - Synchronisation des traductions au build via
locize-cli, compatible serverless, sans saut CDN par requête saveMissingoptionnel pour sauter l'étape manuelle d'ajout des clés en dev- Édition InContext pour les non-développeurs via le plugin
locize - Une séparation propre entre les traductions empaquetées à l'exécution et l'édition côté CMS
🧑💻 Le code complet : github.com/locize/locize-nuxt-example.
Les fondateurs de Locize sont aussi les créateurs d'i18next. En utilisant Locize, vous soutenez directement l'avenir d'i18next.
Si vous voulez un panorama plus large d'i18next, il existe aussi une vidéo crash course i18next :
Voir aussi
- Comment internationaliser une app React Router v7 avec remix-i18next — même forme mais avec fetch CDN live côté client via
i18next-locize-backend - Comment internationaliser une application Remix (Partie 1) et (Partie 2) — walkthroughs Remix v2
- Localisation Vue avec i18next-vue — stack Vue alternative utilisant i18next directement (sans
@nuxtjs/i18n, sans vue-i18n) - Donnez plus de superpouvoirs à vue-i18n — l'histoire de l'intégration vue-i18n vanilla + locizer
- Documentation de
@nuxtjs/i18n