$3b
Patterns pour projets web multilingues. Le choix de la lib dépend du framework — suivre les recommandations natives en 2025.
Framework ?
├── Next.js 14+ App Router → next-intl (recommandé Vercel)
├── Remix → remix-i18next
├── Nuxt 3/4 → @nuxtjs/i18n (officiel)
├── Astro → Astro i18n routing (built-in) + i18next pour les messages
├── SvelteKit → @sveltejs/adapter-node + paraglide-sveltekit (type-safe)
└── React SPA (Vite) → react-i18next (battle-tested)
Besoin type-safety compile-time ?
├── OUI → paraglide (Inlang) — génère du TypeScript pour chaque message
└── NON → next-intl / react-i18next (runtime)
npm install next-intl
// next.config.js
const withNextIntl = require('next-intl/plugin')('./i18n.ts')
module.exports = withNextIntl({
// other config
})
// i18n.ts
import { getRequestConfig } from 'next-intl/server'
import { notFound } from 'next/navigation'
const locales = ['en', 'fr', 'de']
export default getRequestConfig(async ({ locale }) => {
if (!locales.includes(locale as any)) notFound()
return {
messages: (await import(`./messages/${locale}.json`)).default,
}
})
app/
├── [locale]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/page.tsx
│ └── blog/[slug]/page.tsx
├── i18n.ts
└── messages/
├── en.json
├── fr.json
└── de.json
// messages/en.json
{
"HomePage": {
"title": "Welcome to My Site",
"greeting": "Hello, {name}!",
"cart": "{count, plural, =0 {Your cart is empty} =1 {# item in cart} other {# items in cart}}"
},
"Navigation": {
"home": "Home",
"about": "About",
"contact": "Contact"
}
}
// messages/fr.json
{
"HomePage": {
"title": "Bienvenue sur mon site",
"greeting": "Bonjour, {name} !",
"cart": "{count, plural, =0 {Votre panier est vide} =1 {# article dans le panier} other {# articles dans le panier}}"
},
"Navigation": {
"home": "Accueil",
"about": "À propos",
"contact": "Contact"
}
}
import { useTranslations } from 'next-intl'
export default function HomePage() {
const t = useTranslations('HomePage')
return (
<div>
<h1>{t('title')}</h1>
<p>{t('greeting', { name: 'Arnaud' })}</p>
<p>{t('cart', { count: 3 })}</p>
</div>
)
}
// navigation.ts
import { createSharedPathnamesNavigation } from 'next-intl/navigation'
export const locales = ['en', 'fr', 'de'] as const
export const { Link, redirect, usePathname, useRouter } =
createSharedPathnamesNavigation({ locales })
import { Link } from '@/navigation'
<Link href="/about">About</Link>
// Auto-localisé vers /en/about, /fr/about, /de/about
// middleware.ts
import createMiddleware from 'next-intl/middleware'
export default createMiddleware({
locales: ['en', 'fr', 'de'],
defaultLocale: 'en',
localePrefix: 'always', // ou 'as-needed' pour cacher la locale par défaut
localeDetection: true, // détection via Accept-Language header
})
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
}
npm install react-i18next i18next i18next-browser-languagedetector i18next-http-backend
// i18n.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
import HttpBackend from 'i18next-http-backend'
i18n
.use(HttpBackend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en',
supportedLngs: ['en', 'fr', 'de'],
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
interpolation: {
escapeValue: false, // React échappe déjà
},
})
export default i18n
import { useTranslation, Trans } from 'react-i18next'
function Welcome() {
const { t, i18n } = useTranslation()
return (
<div>
<h1>{t('home.title')}</h1>
<Trans i18nKey="home.greeting" values={{ name: 'Arnaud' }}>
Hello, <strong>{'{{name}}'}</strong>!
</Trans>
<button onClick={() => i18n.changeLanguage('fr')}>Français</button>
</div>
)
}
npx nuxi module add @nuxtjs/i18n
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'en', iso: 'en-US', name: 'English', file: 'en.json' },
{ code: 'fr', iso: 'fr-FR', name: 'Français', file: 'fr.json' },
{ code: 'de', iso: 'de-DE', name: 'Deutsch', file: 'de.json' },
],
defaultLocale: 'en',
strategy: 'prefix_except_default', // /about (en), /fr/about, /de/about
langDir: 'locales/',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root',
},
},
})
<script setup lang="ts">
const { t, locale } = useI18n()
</script>
<template>
<div>
<h1>{{ t('home.title') }}</h1>
<NuxtLink :to="switchLocalePath('fr')">Français</NuxtLink>
</div>
</template>
Paraglide compile tes messages en TypeScript à la build → autocomplete + erreurs TS si une clé manque.
npx @inlang/paraglide-js@latest init
// Après compilation
import * as m from '@/paraglide/messages'
const greeting = m.home_greeting({ name: 'Arnaud' })
// Type error si le paramètre est absent ou mal nommé
Advantage : zéro message manquant en prod, tout est vérifié à la compilation.
| Stratégie | Exemple | Pour |
|---|---|---|
| Sub-path | example.com/fr/about | Défaut recommandé (SEO, partage facile) |
| Sub-domain | fr.example.com/about | Sites multi-marchés avec infra séparée |
| TLD | example.fr/about | Sites enterprise avec présence géographique forte |
| Query param | example.com/about?lang=fr | ÉVITER (mauvais SEO) |
Recommandation : Sub-path pour 99 % des cas. Sous-domain uniquement si le client a déjà une infra multi-marchés.
{
"itemCount": "{count, plural, =0 {No items} =1 {One item} other {# items}}"
}
// French — supporte "few" / "many" via Unicode CLDR
{
"itemCount": "{count, plural, =0 {Aucun article} =1 {Un article} other {# articles}}"
}
// Arabic — 6 formes plurielles
{
"itemCount": "{count, plural, =0 {لا توجد عناصر} =1 {عنصر واحد} =2 {عنصران} few {# عناصر} many {# عنصر} other {# عنصر}}"
}
const formatted = new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: 'EUR',
}).format(1234.56)
// '1 234,56 €'
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.56)
// '$1,234.56'
const formatted = new Intl.DateTimeFormat('fr-FR', {
dateStyle: 'long',
timeStyle: 'short',
}).format(new Date())
// '8 avril 2026 à 10:30'
const rtf = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' })
rtf.format(-1, 'day') // 'hier'
rtf.format(-2, 'day') // 'il y a 2 jours'
rtf.format(1, 'hour') // 'dans 1 heure'
new Intl.ListFormat('fr', { style: 'long', type: 'conjunction' })
.format(['pomme', 'banane', 'cerise'])
// 'pomme, banane et cerise'
// app/[locale]/layout.tsx
export default function LocaleLayout({
children,
params: { locale },
}: {
children: React.ReactNode
params: { locale: string }
}) {
const isRTL = ['ar', 'he', 'fa'].includes(locale)
return (
<html lang={locale} dir={isRTL ? 'rtl' : 'ltr'}>
<body>{children}</body>
</html>
)
}
CSS logical properties au lieu de left/right :
/* BON — s'adapte automatiquement au RTL */
.button {
padding-inline-start: 1rem;
padding-inline-end: 2rem;
margin-inline-start: auto;
}
/* MAUVAIS — casse en RTL */
.button {
padding-left: 1rem;
padding-right: 2rem;
margin-left: auto;
}
| Plateforme | Pricing | Avantages |
|---|---|---|
| Crowdin | Free OSS / Paid teams | Ecosystem riche, UI éditeur traducteurs |
| Lokalise | Freemium | Intégration Figma, API REST |
| Tolgee | Free self-host + SaaS | In-context editing dans le navigateur |
| Phrase | Paid enterprise | Workflow translateurs pro, TMS complet |
| Transifex | Paid | Standard de l'industrie depuis 10+ ans |
Workflow type :
NumberFormat pour dates et nombresseo-technical)"Hello " + name) → casse les règles de genre/ordre des motsloginButton: "Se connecter" → préférer auth.login.button (namespacing)lang= et dir= sur <html> → screen readers confus, RTL cassé2026-04-08) → non-lisible, utiliser Intl.DateTimeFormatif count > 1 then "items" else "item") → casse sur arabe, russe, polonaisatum-cms-ecomseo-technical (dans ce plugin)email-templates (à venir)atum-stack-mobile