From atum-stack-web
Internationalization (i18n) pattern library for modern web apps — next-intl (Next.js App Router recommended, routing + messages + date/number formatting), react-i18next (React universel), @nuxtjs/i18n (Nuxt officiel), @paraglidejs/paraglide (type-safe compile-time), Astro i18n (built-in routing), locale-aware routing strategies (sub-path /fr/about, sub-domain fr.example.com, query param ?lang=fr), ICU MessageFormat for pluralization and gender rules, translation management workflows (Crowdin, Lokalise, Tolgee, Phrase), hreflang coordination with SEO, RTL support for Arabic/Hebrew, date/number/currency formatting via Intl.NumberFormat and Intl.DateTimeFormat, and automated translation pipelines. Use when building or maintaining multilingual sites: initial i18n setup, adding a new locale, translation workflow setup, pluralization edge cases, or migrating between i18n libraries. Differentiates from backend i18n (server-side content translation in CMS) by focusing on frontend routing, message extraction, and runtime formatting.
npx claudepluginhub arnwaldn/atum-plugins-collection --plugin atum-stack-webThis skill uses the workspace's default tool permissions.
Patterns pour projets web multilingues. Le choix de la lib dépend du framework — suivre les recommandations natives en 2025.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Guides implementation of event-driven hooks in Claude Code plugins using prompt-based validation and bash commands for PreToolUse, Stop, and session events.
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