Nuxt i18n : la stratégie de routing décide de tout le reste
Mark Toledo
12 janvier 2025

La première fois que j'ai internationalisé une application Nuxt, j'ai perdu deux jours sur un détail : j'avais laissé la stratégie de routing par défaut, et Google indexait la version française sur l'URL racine et sur /fr/. Deux URLs, même contenu, duplication parfaite.
@nuxtjs/i18n est le module officiel, et il fait très bien son travail une fois qu'on a compris que la stratégie de routing est la seule décision réellement structurante. Tout le reste se change en cours de route. Celle-là, non.
Ce guide couvre l'installation, l'usage quotidien, et les trois réglages sur lesquels je vois systématiquement des erreurs en production.
Installation
Installer le module
# Avec npm
npm install @nuxtjs/i18n
# Avec pnpm
pnpm add @nuxtjs/i18n
Configuration de base
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'fr', iso: 'fr-FR', name: 'Français', file: 'fr.json' },
{ code: 'en', iso: 'en-US', name: 'English', file: 'en.json' }
],
defaultLocale: 'fr',
langDir: 'locales/',
strategy: 'prefix_except_default'
}
})
Structure des fichiers de traduction
locales/
├── fr.json
└── en.json
// locales/fr.json
{
"welcome": "Bienvenue",
"home": {
"title": "Accueil",
"description": "Découvrez notre site"
},
"nav": {
"about": "À propos",
"contact": "Contact",
"blog": "Blog"
},
"buttons": {
"submit": "Envoyer",
"cancel": "Annuler"
}
}
// locales/en.json
{
"welcome": "Welcome",
"home": {
"title": "Home",
"description": "Discover our website"
},
"nav": {
"about": "About",
"contact": "Contact",
"blog": "Blog"
},
"buttons": {
"submit": "Submit",
"cancel": "Cancel"
}
}
Utilisation dans les composants
Fonction $t() dans les templates
<template>
<div>
<h1>{{ $t('welcome') }}</h1>
<p>{{ $t('home.description') }}</p>
<nav>
<NuxtLink :to="localePath('/')">{{ $t('nav.about') }}</NuxtLink>
<NuxtLink :to="localePath('/contact')">{{ $t('nav.contact') }}</NuxtLink>
</nav>
<button>{{ $t('buttons.submit') }}</button>
</div>
</template>
useI18n() dans le script setup
<script setup>
const { t, locale, locales, setLocale } = useI18n()
// Utiliser les traductions
const titre = computed(() => t('home.title'))
// Changer de langue
function changerLangue(code) {
setLocale(code)
}
// Obtenir la locale actuelle
console.log(locale.value) // 'fr'
// Lister les locales disponibles
console.log(locales.value) // [{ code: 'fr', ... }, { code: 'en', ... }]
</script>
Le sélecteur de langue, et le piège du drapeau
Composant LanguageSwitcher
<!-- components/LanguageSwitcher.vue -->
<template>
<div class="language-switcher">
<button
v-for="loc in availableLocales"
:key="loc.code"
:class="{ active: loc.code === locale }"
@click="setLocale(loc.code)"
>
{{ loc.name }}
</button>
</div>
</template>
<script setup>
const { locale, locales, setLocale } = useI18n()
const availableLocales = computed(() => {
return locales.value.filter(l => l.code !== locale.value)
})
</script>
<style scoped>
.language-switcher button {
padding: 0.5rem 1rem;
margin: 0 0.25rem;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.language-switcher button.active {
background: #333;
color: white;
}
</style>
Avec drapeaux
<template>
<div class="lang-selector">
<button
v-for="loc in locales"
:key="loc.code"
@click="switchLocalePath(loc.code)"
>
<img :src="`/flags/${loc.code}.svg`" :alt="loc.name" />
<span>{{ loc.name }}</span>
</button>
</div>
</template>
<script setup>
const switchLocalePath = useSwitchLocalePath()
const { locales } = useI18n()
</script>
Routing
Nuxt i18n offre plusieurs stratégies de routing :
prefix_except_default (recommandé)
// nuxt.config.ts
i18n: {
strategy: 'prefix_except_default',
defaultLocale: 'fr'
}
URLs générées :
/→ Français (langue par défaut)/en→ English/about→ Français/en/about→ English
prefix
i18n: {
strategy: 'prefix'
}
URLs générées :
/fr→ Français/en→ English/fr/about→ Français/en/about→ English
prefix_and_default
i18n: {
strategy: 'prefix_and_default'
}
URLs générées :
/et/fr→ Français (les deux fonctionnent)/en→ English
no_prefix
i18n: {
strategy: 'no_prefix'
}
URLs générées :
/→ Langue détectée automatiquement/about→ Langue détectée automatiquement
Naviguer entre les langues sans casser les URLs
localePath()
Génère un chemin localisé :
<template>
<nav>
<!-- Liens internes -->
<NuxtLink :to="localePath('/')">Accueil</NuxtLink>
<NuxtLink :to="localePath('/about')">À propos</NuxtLink>
<!-- Avec paramètres -->
<NuxtLink :to="localePath({ name: 'blog-slug', params: { slug: 'mon-article' } })">
Mon article
</NuxtLink>
</nav>
</template>
<script setup>
const localePath = useLocalePath()
</script>
switchLocalePath()
Change de langue en conservant la page actuelle :
<template>
<div>
<a :href="switchLocalePath('en')">English</a>
<a :href="switchLocalePath('fr')">Français</a>
</div>
</template>
<script setup>
const switchLocalePath = useSwitchLocalePath()
</script>
Traductions dynamiques
Avec variables
// locales/fr.json
{
"greeting": "Bonjour {name} !",
"items": "Vous avez {count} article | Vous avez {count} articles",
"date": "Publié le {date}"
}
<template>
<p>{{ $t('greeting', { name: 'Marie' }) }}</p>
<!-- "Bonjour Marie !" -->
<p>{{ $t('items', { count: 5 }) }}</p>
<!-- "Vous avez 5 articles" -->
<p>{{ $t('date', { date: formattedDate }) }}</p>
</template>
<script setup>
const formattedDate = new Date().toLocaleDateString('fr-FR')
</script>
Pluralisation
// locales/fr.json
{
"car": "pas de voiture | {n} voiture | {n} voitures",
"apple": "aucune pomme | une pomme | {count} pommes"
}
<template>
<p>{{ $t('car', 0) }}</p> <!-- "pas de voiture" -->
<p>{{ $t('car', 1) }}</p> <!-- "1 voiture" -->
<p>{{ $t('car', 5) }}</p> <!-- "5 voitures" -->
</template>
SEO multilingue : hreflang, canonical, et ce que Google exige vraiment
Configuration SEO
// nuxt.config.ts
export default defineNuxtConfig({
i18n: {
locales: [
{ code: 'fr', iso: 'fr-FR', name: 'Français' },
{ code: 'en', iso: 'en-US', name: 'English' }
],
baseUrl: 'https://monsite.fr',
// Ajoute automatiquement les balises hreflang
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root'
}
}
})
Meta tags localisés
<script setup>
const { t, locale } = useI18n()
const localePath = useLocalePath()
// SEO dynamique par langue
useSeoMeta({
title: () => t('home.title'),
description: () => t('home.description'),
ogTitle: () => t('home.title'),
ogDescription: () => t('home.description'),
ogLocale: () => locale.value
})
// Liens canoniques et alternates
useHead({
link: [
{
rel: 'canonical',
href: `https://monsite.fr${localePath('/')}`
}
]
})
</script>
Balises hreflang automatiques
Nuxt i18n génère automatiquement :
<link rel="alternate" hreflang="fr" href="https://monsite.fr/" />
<link rel="alternate" hreflang="en" href="https://monsite.fr/en/" />
<link rel="alternate" hreflang="x-default" href="https://monsite.fr/" />
Côté serveur
Serveur multilingue
// server/api/messages.get.ts
export default defineEventHandler(async (event) => {
const locale = getHeader(event, 'accept-language')?.split(',')[0] || 'fr'
const messages = {
fr: { welcome: 'Bienvenue', success: 'Opération réussie' },
en: { welcome: 'Welcome', success: 'Operation successful' }
}
return messages[locale] || messages.fr
})
Configuration avancée
Lazy loading des traductions
// nuxt.config.ts
i18n: {
lazy: true,
langDir: 'locales/',
locales: [
{ code: 'fr', file: 'fr.json' },
{ code: 'en', file: 'en.json' },
{ code: 'de', file: 'de.json' }
]
}
Les fichiers de traduction sont chargés uniquement quand nécessaire.
Détection automatique de la langue
i18n: {
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_lang',
redirectOn: 'root', // Redirige uniquement sur la page d'accueil
alwaysRedirect: false,
cookieCrossOrigin: false,
cookieSecure: true
}
}
Personnalisation des routes
i18n: {
customRoutes: 'config',
pages: {
about: {
fr: '/a-propos',
en: '/about'
},
'blog/[slug]': {
fr: '/articles/[slug]',
en: '/blog/[slug]'
}
}
}
Ce que je fais systématiquement
1. Organiser les traductions
// locales/fr.json
{
"common": {
"loading": "Chargement...",
"error": "Une erreur est survenue",
"save": "Enregistrer"
},
"pages": {
"home": { "title": "Accueil" },
"about": { "title": "À propos" }
},
"components": {
"header": { "logo_alt": "Logo du site" },
"footer": { "copyright": "© 2025" }
}
}
2. Typage TypeScript
// types/i18n.d.ts
declare module 'vue-i18n' {
export interface DefineLocaleMessage {
welcome: string
home: {
title: string
description: string
}
}
}
3. Fallback intelligent
i18n: {
fallbackLocale: 'fr',
// Si une traduction manque en anglais, utilise le français
}
Pour finir
Si je ne devais retenir qu'une chose de mes déploiements multilingues : tranchez la stratégie de routing avant d'écrire la première traduction. C'est la seule décision qui coûte cher à revenir dessus, parce qu'elle change toutes vos URLs, donc tout votre référencement acquis.
Le reste — le lazy loading, la détection automatique, le typage des clés — s'ajoute quand le besoin se présente, sans rien casser. Et laissez le module générer vos balises hreflang : je n'ai jamais vu une implémentation manuelle survivre à trois langues sans oublier la réciprocité.
Ressources complémentaires :
