Table des matières
Vue i18n a considérablement mûri avec la Composition API. Si vous suivez encore les modèles de l'Options API tirés de tutoriels écrits en 2020, vous passez à côté de beaucoup — notamment en matière de sécurité des types, de chargement différé et de messages distribués via CDN.
Voici un guide pratique pour construire des applications Vue 3 multilingues qui passent à l'échelle : des traductions par composant au changement de locale basé sur les routes, jusqu'à l'intégration Nuxt 3. Nous aborderons les modèles qui tiennent la route à l'échelle enterprise, pas seulement ceux qui fonctionnent pour des démonstrations.
Configurer vue-i18n avec la Composition API
Commencez avec le mode Composition API. Le mode Options API existe toujours pour la rétrocompatibilité, mais il ne vous offre pas les modèles composables qui rendent les grandes bases de code gérables.
npm install vue-i18n@9
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
export type MessageSchema = typeof import('./locales/en.json')
export const i18n = createI18n<[MessageSchema], 'en' | 'fr' | 'de'>({
legacy: false, // Composition API mode
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {} // loaded lazily
}
})
Définir legacy: false est l'étape cruciale. Cela déverrouille useI18n() et toute la surface de la Composition API.
useI18n() : le composable central
Chaque composant qui a besoin de traductions utilise useI18n(). Le modèle de base :
// src/components/WelcomeHeader.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<h1>{{ t('welcome.heading') }}</h1>
<p>{{ t('welcome.subheading', { plan: 'Pro' }) }}</p>
</template>
La fonction t() est réactive. Lorsque locale change, tous les appels t() se réévaluent automatiquement. Le changement de locale fonctionne donc sans aucun re-rendu manuel.
Portée globale vs. portée locale
C'est là que la plupart des équipes se trompent à grande échelle. vue-i18n supporte deux portées, et les mélanger sans précaution crée des problèmes de maintenance.
Portée globale — messages définis au niveau de l'instance i18n, accessibles partout :
// src/i18n/locales/en.json
{
"nav.home": "Home",
"nav.pricing": "Pricing",
"common.save": "Save",
"common.cancel": "Cancel"
}
Portée locale — messages limités à un composant spécifique, définis dans le composant lui-même :
// src/components/BillingForm.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n({
messages: {
en: {
title: 'Billing Information',
cardNumber: 'Card number',
expiryDate: 'Expiry date',
errors: {
invalidCard: 'Please check your card number'
}
},
fr: {
title: 'Informations de facturation',
cardNumber: 'Numéro de carte',
expiryDate: "Date d'expiration",
errors: {
invalidCard: 'Veuillez vérifier votre numéro de carte'
}
}
}
})
</script>
La règle empirique : la navigation, les actions communes et les messages d'erreur qui apparaissent dans toute l'application vont en portée globale. Le texte d'interface spécifique à une fonctionnalité va en portée locale, colocalisé avec le composant.
Les messages en portée locale ne polluent pas l'espace de noms global et permettent de voir clairement quelles traductions appartiennent à quelle fonctionnalité. Lorsque vous supprimez un composant, vous supprimez ses traductions avec lui.
Sécurité des types avec TypeScript
Sans sécurité des types, l'i18n devient un problème d'erreurs à l'exécution. Vous ne saurez pas qu'une clé de traduction manque jusqu'à ce qu'un utilisateur voie une chaîne vide en production.
vue-i18n supporte une intégration TypeScript complète. La configuration nécessite une déclaration de type :
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import enMessages from './locales/en.json'
type MessageSchema = typeof enMessages
declare module 'vue-i18n' {
export interface DefineLocaleMessage extends MessageSchema {}
}
export const i18n = createI18n<false, MessageSchema>({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
Maintenant t() est entièrement typé. Passez une clé qui n'existe pas, et TypeScript la détecte à la compilation :
const { t } = useI18n()
t('nav.home') // OK
t('nav.homee') // TypeScript error: Argument of type '"nav.homee"' is not assignable
t('welcome.heading') // OK — if it exists in the schema
À grande échelle, c'est la différence entre l'i18n comme fardeau de maintenance et l'i18n comme fonctionnalité invisible. Votre IDE complète automatiquement les clés. Les fautes de frappe échouent au build, pas en production.
Chargement différé des locales
Charger toutes les locales d'emblée convient aux petites applications. Pour les applications enterprise avec 20+ langues et des milliers de clés, ce n'est pas viable. La solution est le chargement différé : charger la locale active à la demande.
// src/i18n/loader.ts
import { i18n } from './index'
const loadedLocales = new Set<string>(['en']) // en loaded at init
export async function loadLocale(locale: string): Promise<void> {
if (loadedLocales.has(locale)) return
const messages = await import(`./locales/${locale}.json`)
i18n.global.setLocaleMessage(locale, messages.default)
loadedLocales.add(locale)
}
export async function setLocale(locale: string): Promise<void> {
await loadLocale(locale)
i18n.global.locale.value = locale as any
document.documentElement.setAttribute('lang', locale)
}
Utilisez ceci dans un composant de sélecteur de locale :
// src/components/LocaleSwitcher.vue
<script setup lang="ts">
import { setLocale } from '@/i18n/loader'
const locales = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
]
async function handleChange(code: string) {
await setLocale(code)
}
</script>
<template>
<select @change="e => handleChange((e.target as HTMLSelectElement).value)">
<option v-for="l in locales" :key="l.code" :value="l.code">
{{ l.label }}
</option>
</select>
</template>
Chaque bundle de locale est un chunk séparé. Les utilisateurs ne téléchargent que la langue qu'ils utilisent.
Changement de locale basé sur les routes
Pour les applications où le référencement est critique, la locale doit figurer dans l'URL — /en/pricing, /fr/tarifs. Cette approche donne à chaque locale sa propre URL indexable et permet aux moteurs de recherche de comprendre la structure linguistique de votre site.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { setLocale } from '@/i18n/loader'
const SUPPORTED_LOCALES = ['en', 'fr', 'de']
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/:locale(en|fr|de)',
component: () => import('@/layouts/LocaleLayout.vue'),
children: [
{ path: '', component: () => import('@/pages/Home.vue') },
{ path: 'pricing', component: () => import('@/pages/Pricing.vue') },
{ path: 'features', component: () => import('@/pages/Features.vue') },
]
},
{
path: '/:pathMatch(.*)*',
redirect: to => `/en/${to.params.pathMatch}`
}
]
})
router.beforeEach(async (to) => {
const locale = to.params.locale as string
if (SUPPORTED_LOCALES.includes(locale)) {
await setLocale(locale)
}
})
export default router
Le composant de mise en page de locale gère les balises <link rel="alternate" hreflang> pour le SEO :
// src/layouts/LocaleLayout.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useHead } from '@vueuse/head'
const route = useRoute()
const locale = route.params.locale as string
const LOCALES = ['en', 'fr', 'de']
useHead({
htmlAttrs: { lang: locale },
link: LOCALES.map(l => ({
rel: 'alternate',
hreflang: l,
href: `https://example.com/${l}${route.path.replace(`/${locale}`, '')}`
}))
})
</script>
<template>
<RouterView />
</template>
Pluralisation et formatage
La pluralisation est le point de défaillance des implémentations i18n naïves. L'anglais a deux formes plurielles. L'arabe en a six. Le russe utilise des formes différentes selon le dernier chiffre du nombre. vue-i18n gère cela correctement si vous l'utilisez correctement.
Pluralisation :
// en.json
{
"items": "no items | one item | {count} items"
}
// ru.json — Russian plural rules
{
"items": "нет элементов | {count} элемент | {count} элемента | {count} элементов"
}
const { t, n, d } = useI18n()
// Pluralisation
t('items', 0) // "no items"
t('items', 1) // "one item"
t('items', 42) // "42 items"
// Formatage des nombres avec des formats nommés
n(1234567.89, 'currency') // "$1,234,567.89" en en, "1 234 567,89 €" en fr
Définissez les formats nommés au niveau de l'instance i18n :
export const i18n = createI18n({
legacy: false,
locale: 'en',
numberFormats: {
en: {
currency: { style: 'currency', currency: 'USD', notation: 'standard' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
},
fr: {
currency: { style: 'currency', currency: 'EUR', notation: 'standard' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
}
},
datetimeFormats: {
en: {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
},
fr: {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
}
}
})
Maintenant n() et d() formatent correctement pour chaque locale sans aucune logique par composant.
Messages distribués via CDN vs. JSON intégré
Intégrer les fichiers JSON de locale dans votre application a un vrai inconvénient : chaque mise à jour de traduction nécessite un redéploiement. Pour les équipes qui mettent fréquemment à jour les traductions — tests A/B sur le texte, corrections d'erreurs, localisation pour de nouveaux marchés — il s'agit d'une contrainte significative.
L'alternative est la distribution via CDN. Vos fichiers de traduction résident sur un CDN, et votre application les récupère à l'exécution. Cela fonctionne avec n'importe quelle configuration Vue — Nuxt 3, SPA Vite ou rendu serveur personnalisé.
// src/i18n/cdn-loader.ts
const CDN_BASE = 'https://cdn.example.com/i18n'
export async function loadLocaleFromCDN(locale: string): Promise<Record<string, string>> {
const res = await fetch(`${CDN_BASE}/${locale}.json`, {
headers: { 'Cache-Control': 'max-age=3600' }
})
if (!res.ok) throw new Error(`Failed to load locale: ${locale}`)
return res.json()
}
L'inconvénient est une requête réseau au chargement de la locale. Avec un CDN correctement configuré avec mise en cache, il s'agit généralement d'un accès au cache après le premier chargement. L'avantage est que votre équipe de traduction peut publier des mises à jour de texte instantanément, sans toucher au pipeline de déploiement du code.
Chez Better i18n, la distribution via CDN est intégrée à la plateforme. Les traductions passent par un workflow de révision, sont publiées et sont instantanément disponibles dans le monde entier via le CDN — pas de redéploiement, pas de PR pour corriger une faute de frappe. La page des fonctionnalités explique le fonctionnement du pipeline de distribution, et si vous évaluez cela pour un projet Vue spécifiquement, la page pour les développeurs contient des exemples d'intégration SDK.
Intégration Nuxt 3
Nuxt 3 dispose d'un support i18n de première classe via @nuxtjs/i18n, qui encapsule vue-i18n et ajoute le rendu côté serveur, la génération de routes et des utilitaires SEO.
npm install @nuxtjs/i18n
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'en', iso: 'en-US', file: 'en.json' },
{ code: 'fr', iso: 'fr-FR', file: 'fr.json' },
{ code: 'de', iso: 'de-DE', file: 'de.json' }
],
defaultLocale: 'en',
langDir: 'i18n/locales/',
lazy: true,
strategy: 'prefix_except_default',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root'
}
}
})
Avec strategy: 'prefix_except_default', les routes anglaises n'ont pas de préfixe (/pricing) tandis que les autres locales en ont un (/fr/tarifs). Nuxt gère la génération des routes automatiquement.
Dans les composants, useI18n() fonctionne de manière identique à une application non-Nuxt :
// pages/pricing.vue
<script setup lang="ts">
const { t } = useI18n()
useSeoMeta({
title: t('pricing.meta.title'),
description: t('pricing.meta.description')
})
</script>
L'intégration SSR signifie que le contenu traduit est dans le HTML initial — pas de flash de contenu non traduit, et les moteurs de recherche voient la version localisée.
Mise à l'échelle du workflow de développement
À mesure que votre surface de traduction augmente, le goulot d'étranglement passe de la configuration technique au workflow. Voici quelques modèles qui tiennent la route à grande échelle :
Gardez des clés de traduction sémantiques, pas positionnelles. nav.pricing vieillit mieux que link3. Lorsque la navigation est repensée, link3 devient sans signification. nav.pricing reste correct.
Traitez les traductions manquantes comme des erreurs de build. Configurez votre build pour avertir ou échouer lorsqu'une clé existe dans la locale par défaut mais est absente dans une locale cible. Détectez-le avant que les utilisateurs ne le voient.
Automatisez l'extraction des traductions. Exécuter vue-i18n-extract ou des outils similaires dans le cadre de la CI garantit que chaque nouvelle chaîne est signalée pour traduction avant sa mise en production.
Séparez la révision des traductions de la révision du code. La qualité de la traduction est une compétence différente de la qualité du code. Si les traducteurs doivent ouvrir une PR pour corriger une faute de frappe, vous avez créé des frictions inutiles. Utilisez une plateforme de traduction où la révision linguistique se fait dans la langue, pas dans Git.
Les fondements techniques décrits dans cet article couvrent le côté ingénierie. Le côté workflow — faire réviser les traductions, gérer les accès des traducteurs, maintenir des glossaires cohérents entre les langues — est là où des outils dédiés apportent un retour sur investissement disproportionné.