Aller au contenu

Articles

Site multilingue Next.js en 22 langues : les leçons de GAGA

Routage par locale, serbe latin et cyrillique, Intl, polices, CI des traductions et cours de change à jour : les leçons d’un site Next.js en 22 langues.

Équipe d’ingénierie de sigmacode.io10 min de lecture

Sur cette page (11)
  1. Une URL propre à chaque locale
  2. Deux alphabets, une seule langue
  3. Formater nombres, devises et dates avec Intl
  4. Polices et couverture des glyphes
  5. Un workflow de traduction qui tient à 22 langues
  6. Des données en temps réel, sans valeurs périmées
  7. Une seule source de vérité pour PDF, Excel, CSV et XML
  8. Le SEO sur de nombreuses locales
  9. Accessibilité
  10. Tests : routes × locales
  11. Ce qu’il faut prévoir

GAGA Menjačnica est un bureau de change et un négociant en métaux précieux qui compte plusieurs agences à Novi Sad, en Serbie. Notre équipe a construit sa plateforme, menjacnicegaga.rs, avec Next.js et React sur Vercel. Elle fonctionne en 22 langues, dont le serbe en alphabet latin et en cyrillique, l’allemand, le chinois, le russe, le turc, l’ukrainien, le grec et le bulgare. Elle affiche les cours d’achat et de vente en direct à côté des cours de référence de la Banque nationale de Serbie, et propose un convertisseur de devises, des listes de cours téléchargeables dans cinq formats, un localisateur d’agences et des guides sur l’or et l’argent d’investissement.

À vingt-deux langues, l’internationalisation n’est plus une fonctionnalité. À cette échelle, elle façonne l’architecture. Cet article passe en revue ce qui compte, selon nous, quand on construit une plateforme de ce type. Il s’adresse aux CTO, aux product owners et aux développeurs frontend qui en préparent une. Pour le projet lui-même, consultez l’étude de cas GAGA.

Une URL propre à chaque locale#

La décision la plus importante vient en premier : chaque version linguistique de chaque page a besoin de sa propre URL, stable et explorable par les moteurs. Ne changez pas de langue au moyen d’un cookie ou en analysant l’en-tête Accept-Language. Les moteurs de recherche ne peuvent pas indexer cela, les utilisateurs ne peuvent pas le partager et les CDN ne peuvent pas le mettre en cache proprement.

Nous recommandons un préfixe de locale dans le chemin, par exemple /sr/..., /de/... et /zh/.... Avec l’App Router de Next.js, cela se traduit par un segment [locale] à la racine de l’application. Le proxy de requêtes (proxy.ts, anciennement middleware) peut rediriger une première visite vers une langue par défaut pertinente, d’après la langue du navigateur. Ensuite, l’URL est l’unique source de vérité, et le sélecteur de langue doit pointer vers la même page dans l’autre locale, non vers la page d’accueil de cette locale.

Définissez tôt vos identifiants de locale et utilisez partout des balises BCP 47. Le serbe à lui seul en exige deux, sr-Latn et sr-Cyrl. Faire correspondre ces balises aux segments d’URL, aux valeurs hreflang et aux locales Intl en un seul endroit évite bien des ennuis par la suite.

hreflang, x-default et sitemaps#

Chaque page doit lister toutes ses versions alternatives, elle-même comprise, et ajouter un x-default pour les utilisateurs dont vous ne prenez pas la langue en charge. Dans l’App Router, l’API metadata s’en charge pour vous :

ts
// app/[locale]/rates/page.tsx
import type { Metadata } from "next";

const BASE = "https://example.com";
const LOCALES = ["sr-Latn", "sr-Cyrl", "en", "de", "zh", "ru"] as const;
const segment = (l: string) => l.toLowerCase(); // "sr-Latn" -> "sr-latn"

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
  const { locale } = await params;
  const path = "/rates";
  const languages: Record<string, string> = Object.fromEntries(
    LOCALES.map((l) => [l, `${BASE}/${segment(l)}${path}`])
  );
  languages["x-default"] = `${BASE}/en${path}`;

  return {
    alternates: { canonical: `${BASE}/${locale}${path}`, languages },
  };
}

Générez votre sitemap à partir de la même liste de locales et du même registre de routes, avec les alternatives de chaque entrée. Dès que les balises hreflang et les entrées du sitemap proviennent de chemins de code distincts, elles finissent par diverger, et les moteurs de recherche cessent discrètement de se fier aux unes comme aux autres.

Deux alphabets, une seule langue#

Le serbe s’écrit en alphabet latin comme en cyrillique, et beaucoup de lecteurs ont une préférence marquée. Traitez-les comme deux locales à part entière, et non comme une locale unique dotée d’une bascule d’affichage. Chacune a sa propre URL, sa propre valeur hreflang et son propre attribut lang.

Il est tentant de tout rédiger dans un alphabet et de translittérer l’autre automatiquement. Pour le texte courant, une étape de translittération bien testée peut constituer un point de départ raisonnable. Mais ne l’appliquez pas aveuglément à tout :

  • Noms propres et marques. Les noms de sociétés, les noms de produits et les mots étrangers conservent souvent leur forme latine, même dans un texte en cyrillique. Un convertisseur mécanique les transformera « obligeamment » en quelque chose que personne n’a écrit.
  • Ambiguïté des digrammes. Les digrammes latins nj, lj et correspondent en général à une seule lettre cyrillique, mais pas toujours. Les mots composés et les emprunts étrangers font exception. Du cyrillique vers le latin, la conversion est déterministe. Du latin vers le cyrillique, elle ne l’est pas.
  • URL, codes et identifiants. Les codes de devise comme EUR, les adresses e-mail, les slugs et tout ce qui se trouve dans des placeholders d’interpolation ne doivent jamais être translittérés.
  • Recherche et tri. Un utilisateur peut saisir du latin dans un champ de recherche sur une page en cyrillique. Normalisez les deux côtés avant de comparer.

Notre recommandation : stockez la source en cyrillique (la direction déterministe), ou maintenez deux catalogues distincts, et tenez une courte liste d’exceptions dont un locuteur natif a la responsabilité. Tout ce qu’une machine a produit doit être relu avant la mise en ligne.

Formater nombres, devises et dates avec Intl#

Sur un site de cours de change, les nombres sont le produit. Le serbe utilise la virgule comme séparateur décimal, l’allemand sépare les milliers par un point, et les utilisateurs chinois attendent encore d’autres conventions. Ne formatez rien à la main. Utilisez les API Intl natives et passez-leur la balise de locale complète :

ts
const rateFormatter = (locale: string) =>
  new Intl.NumberFormat(locale, {
    minimumFractionDigits: 4,
    maximumFractionDigits: 4,
  });

const moneyFormatter = (locale: string, currency: string) =>
  new Intl.NumberFormat(locale, { style: "currency", currency });

const asOf = (locale: string, date: Date) =>
  new Intl.DateTimeFormat(locale, {
    dateStyle: "long",
    timeStyle: "short",
    timeZone: "Europe/Belgrade",
  }).format(date);

rateFormatter("sr-Latn").format(117.1234);   // "117,1234"
rateFormatter("de").format(117.1234);        // "117,1234"
moneyFormatter("en", "EUR").format(1250);    // "€1,250.00"
asOf("sr-Cyrl", new Date());                 // e.g. "18. септембар 2026. 10:30" (exact output depends on the ICU version)

Quelques points à anticiper. Définissez explicitement le fuseau horaire. Sinon, les pages rendues côté serveur utilisent celui du serveur, généralement UTC. Créez les formateurs une seule fois et réutilisez-les, car les construire à répétition dans un grand tableau finit par coûter cher. Et fixez le nombre de décimales dans la couche de données autant que dans l’interface, pour que les exports et l’écran concordent.

Polices et couverture des glyphes#

Vingt-deux langues, cela signifie des caractères latins, latins étendus (serbe, croate, turc), cyrilliques (serbe, russe, ukrainien, bulgare), grecs et CJK. Rares sont les polices de marque qui couvrent tout cela, et celles qui le font sont lourdes.

L’essentiel :

  • Vérifiez la couverture alphabet par alphabet avant de choisir une police. Testez de vraies chaînes, pas du « Lorem ipsum ». Le cyrillique serbe a ses propres lettres et des formes italiques locales, l’ukrainien possède des lettres absentes du russe, et le bulgare préfère ses propres dessins de glyphes.
  • Créez des sous-ensembles par alphabet et chargez-les selon la locale. next/font prend en charge des sous-ensembles comme latin, latin-ext, cyrillic et greek. Une page allemande ne doit pas télécharger de glyphes cyrilliques.
  • N’auto-hébergez pas une police CJK complète sur chaque page. Les polices chinoises peuvent peser plusieurs mégaoctets. Une pile de polices système pour le CJK, par exemple "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, est souvent le bon compromis.
  • Concevez une pile de polices de repli explicite et définissez des polices de repli aux métriques compatibles, afin que le texte ne se décale pas au chargement de la police web.

Un workflow de traduction qui tient à 22 langues#

Avec deux ou trois langues, un tableur et un peu de discipline suffisent. À 22, il vous faut un pipeline.

Des catalogues de messages aux clés stables. Utilisez un fichier JSON par locale, avec des clés nommées d’après leur sens, comme rates.table.buy, et jamais d’après le texte anglais. Employez ICU MessageFormat pour les pluriels et l’interpolation. Les règles de pluriel varient beaucoup : le russe, l’ukrainien et le serbe ont plusieurs formes, le chinois n’en a aucune.

La parité des clés dans la CI. Une clé manquante dans une locale est le bug le plus courant d’un site multilingue, et c’est le plus facile à détecter automatiquement :

ts
// scripts/check-i18n.ts — run in CI, fail the build on drift
import { readdirSync, readFileSync } from "node:fs";

const dir = "messages";
const flatten = (o: Record<string, unknown>, p = ""): string[] =>
  Object.entries(o).flatMap(([k, v]) =>
    v && typeof v === "object" ? flatten(v as Record<string, unknown>, `${p}${k}.`) : [`${p}${k}`]
  );

const load = (f: string) => new Set(flatten(JSON.parse(readFileSync(`${dir}/${f}`, "utf8"))));
const source = load("en.json");
let failed = false;

for (const file of readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "en.json")) {
  const keys = load(file);
  const missing = [...source].filter((k) => !keys.has(k));
  const extra = [...keys].filter((k) => !source.has(k));
  if (missing.length || extra.length) {
    failed = true;
    console.error(`${file}: missing ${missing.length}, extra ${extra.length}`, { missing, extra });
  }
}
process.exit(failed ? 1 : 0);

Étendez ce même script pour vérifier que les placeholders d’interpolation concordent d’une locale à l’autre. Un nom de placeholder traduit casse à l’exécution, pas à la compilation.

Une traduction assistée par IA, avec relecture humaine. La traduction automatique et par LLM produit désormais de bons premiers jets et, à 22 langues, cela change l’équation économique. Mais un bureau de change manie de l’argent et de la confiance. Les libellés de cours, les mentions légales et les guides d’investissement doivent être relus par une personne qui maîtrise la langue avant leur mise en ligne. Donnez du contexte aux traducteurs : captures d’écran, limites de caractères et glossaire des termes figés tels que « cours d’achat », « cours de vente » et « cours de référence ».

Des données en temps réel, sans valeurs périmées#

Les cours de change évoluent au fil de la journée. Le piège consiste à les mettre en cache aussi agressivement que le reste d’un site Next.js pensé d’abord pour le statique.

Les stratégies générales que nous recommandons :

  • Séparez la coquille des données. La mise en page, les traductions et les guides peuvent être statiques ou rarement revalidés. Le tableau des cours doit avoir sa propre fenêtre de revalidation, courte, ou bien être chargé côté client ou diffusé en streaming.
  • Gardez une revalidation courte et délibérée. Choisissez une fenêtre qui correspond à la fréquence réelle de variation des cours, et documentez-la. Dans la mesure du possible, déclenchez une revalidation à la demande lors de la publication de nouveaux cours, plutôt que de vous en remettre à un simple minuteur.
  • Mettez en cache à l’edge avec prudence. Un s-maxage court associé à stale-while-revalidate garde les pages rapides, mais assurez-vous que la fenêtre de péremption est acceptable pour le métier.
  • Affichez toujours un horodatage « à jour au », formaté selon la locale et dans le fuseau horaire de l’agence. C’est la réponse honnête à la question que soulève tout système de cache : de quand datent ces données ?

Une seule source de vérité pour PDF, Excel, CSV et XML#

GAGA publie sa liste des cours en téléchargement aux formats PDF, JPG, Excel, CSV et XML. Cinq formats, ce sont cinq occasions pour les chiffres de diverger.

La règle : construisez chaque export à partir de la même structure de données normalisée, celle-là même qui alimente le tableau à l’écran. Placez les arrondis, l’ordre et les métadonnées de devise dans cette structure, et non dans chaque exporteur. Chaque format devient alors un simple sérialiseur. Les points à anticiper :

  • Le CSV exige un séparateur et un encodage déclarés. Dans de nombreuses locales européennes, Excel attend un point-virgule et gère mieux l’UTF-8 avec un BOM.
  • Excel doit recevoir de vraies cellules numériques avec des formats de nombre, et non des chaînes préformatées, pour que les utilisateurs puissent faire des calculs.
  • Le XML a besoin d’un schéma stable et documenté, car quelqu’un s’y intégrera.
  • Les PDF et les images doivent embarquer des polices qui couvrent l’alphabet demandé, ce qui nous ramène à la couverture des glyphes.

Intégrez aussi l’horodatage « à jour au » dans chaque export.

Le SEO sur de nombreuses locales#

Au-delà de hreflang et des sitemaps :

  • Traduisez les titres, les descriptions et les métadonnées Open Graph pour chaque locale. Ne laissez pas de métadonnées anglaises sur une page grecque.
  • Ne localisez les slugs que si vous pouvez les garder stables. Un slug modifié dans une locale, ce sont des redirections et un cluster hreflang cassé.
  • Évitez les doublons au contenu pauvre. Si une locale n’a qu’un contenu partiel, demandez-vous si elle doit déjà être indexée.
  • Les URL canoniques doivent pointer vers la page elle-même, jamais vers une autre version linguistique.

Accessibilité#

Définissez lang sur l’élément html pour chaque locale, avec la balise complète (sr-Latn, sr-Cyrl), afin que les lecteurs d’écran choisissent la bonne voix et la bonne prononciation. Lorsqu’une expression dans une autre langue apparaît dans une page, balisez-la avec son propre lang.

Même si vous ne prenez en charge aucune langue s’écrivant de droite à gauche aujourd’hui, prévoyez-le. Utilisez les propriétés logiques CSS comme margin-inline-start au lieu de margin-left, et déduisez dir de la locale. Ajouter l’arabe plus tard coûte bien moins cher quand la mise en page ne présuppose pas une écriture de gauche à droite.

Tests : routes × locales#

Avec 22 locales, un bug qui n’apparaît que dans l’une d’elles passe facilement inaperçu. Nous recommandons un smoke test automatisé qui parcourt chaque route publique dans chaque locale et vérifie les fondamentaux :

  • la page renvoie un code 200 et s’affiche sans erreur d’exécution ;
  • html porte le bon lang ;
  • les alternatives hreflang sont complètes et pointent vers des URL qui existent ;
  • aucune clé de message brute (comme rates.table.buy) ni chaîne vide n’est visible ;
  • le convertisseur et le tableau des cours affichent les nombres dans le format attendu.

Ajoutez des captures visuelles de référence pour les langues les plus longues. Les libellés allemands et grecs cassent souvent des mises en page qui semblaient impeccables en anglais.

Ce qu’il faut prévoir#

Si vous lancez une plateforme Next.js multilingue, voici les décisions à prendre dès le premier jour : une stratégie d’URL préfixées par la locale, un registre unique de locales qui pilote le routage, les métadonnées et les sitemaps, une politique claire pour les alphabets et la translittération, Intl pour tout le formatage, un plan de polices par alphabet, des contrôles de parité des traductions dans la CI, une politique de fraîcheur explicite pour les données en direct et une source de vérité unique pour chaque export.

Rien de tout cela n’est exotique, mais il est bien moins coûteux de le prévoir dès la conception que de l’ajouter après coup. Nos travaux sont dirigés par un tech lead avec plus de 20 ans d’expérience, et c’est précisément le type de fondations auquel nous nous consacrons dans Web & plateformes. Si vous préparez un projet similaire, contactez-nous.

Vous avez un projet en tête ?

Décrivez votre projet en quelques lignes. Un ingénieur senior vous répond sous 24 heures les jours ouvrés ; suivent un avis honnête, un périmètre clair et une proposition à prix fixe ou par jalons, en général en quelques jours ouvrés.

Vous préférez d’abord écrire ? Écrivez-nous

Vous échangez directement avec Ing. Ismet Mesic, Tech lead.