Frameworks

Nuxt i18n : la stratégie de routing décide de tout le reste

Mark Toledo

Mark Toledo

12 janvier 2025

Nuxt i18n : la stratégie de routing décide de tout le reste

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 :