Six langues, un codebase : l'i18n Next.js comme architecture
vaynerov.com/de/leistungen n'est pas une page anglaise avec un sélecteur de langue vissé dessus. C'est une autre URL, un autre catalogue, un autre jeu d'alternatives hreflang et une part de JavaScript soigneusement rationnée. Voici l'architecture qui tient sous six langues.
Sur cette page
Regardez l'URL : vaynerov.com/de/leistungen. Rien n'y est anglais. Le segment de locale dit allemand, et le slug aussi — Leistungen, les services. Un visiteur arrivé depuis un résultat de recherche allemand ne voit jamais un mot d'anglais dans la barre d'adresse, et un robot qui indexe cette page indexe un chemin allemand, pas un chemin anglais déguisé en ?lang=de.
Cette seule décision — que le slug fait partie de la traduction — est la raison pour laquelle l'internationalisation de ce site est un problème d'architecture plutôt qu'un problème de traduction. La traduction, c'est un tableur. L'architecture, c'est ce qui arrive quand une route canonique doit devenir six URL, six entrées de sitemap, six alternatives hreflang et un seul arbre React partagé, sans doubler le code ni tripler les octets qui atteignent un téléphone.
- 6
- locales — en, ru, es, de, fr, pt
- 28
- entrées de chemins localisés
- 1.19 MB
- de catalogues de messages sur disque
- 11
- espaces de noms envoyés au navigateur
La forme mesurée de la couche i18n : ce qui est routé, ce qui est stocké, et la petite fraction qu'un navigateur télécharge réellement.
Le slug fait partie de la traduction
Nous utilisons next-intl avec localePrefix: "as-needed" et l'anglais comme locale par défaut. Les URL anglaises ne portent aucun préfixe — /services est la page services canonique — tandis que les cinq autres locales sont toujours préfixées. L'arbitrage est délibéré : as-needed coûte une décision de routage à chaque requête, et achète des URL canoniques propres et sans préfixe pour la locale vers laquelle pointe la plupart des liens.
Par-dessus le préfixe se trouve la carte des chemins : 28 entrées, chacune appariant une route interne à six slugs publics. Services, réalisations, tarifs, le journal, à propos, carrières, investisseurs, contact, devis, confidentialité et conditions se traduisent tous.
const pathnames = {
"/services": {
en: "/services",
ru: "/uslugi",
es: "/servicios",
de: "/leistungen",
fr: "/services",
pt: "/servicos",
},
// …27 more
};Une route, six URL. Le français tombe par hasard sur le même mot que l'anglais — sans dommage, puisque le préfixe de locale lève l'ambiguïté : /services et /fr/services sont deux pages différentes.
| Route | ru | es | de | fr | pt |
|---|---|---|---|---|---|
| services | uslugi | servicios | leistungen | services | servicos |
| work | raboty | trabajos | projekte | realisations | projetos |
| pricing | tseny | precios | preise | tarifs | precos |
| articles | stati | articulos | artikel | articles | artigos |
Quatre des 28 entrées de chemins. Les slugs russes sont des translittérations latines, si bien que chaque chemin du site reste en ASCII dans une ligne de log, un résultat de recherche et un tableau de bord d'analytique.
La partie intéressante du tableau est ce qui n'y figure pas. Une poignée de routes ne se traduisent jamais : /museum, /games, /roadmap, /cookies, /nda. Deux raisons distinctes, toutes deux à énoncer clairement. Les noms de code produits sont des noms — le Reliquary s'appelle le Reliquary dans toutes les langues, et un visiteur allemand qui le cherche tape le nom que nous lui avons donné, pas sa traduction. Le boilerplate juridique relève du cas inverse : ces pages changent rarement, sont lues par des juristes et des robots plus que par des humains, et un chemin stable dans les six locales vaut mieux qu'un chemin localisé.
Un middleware, trois dérivations
Chaque requête rencontre un routeur fait maison enveloppé autour de createIntlMiddleware. La plupart passent directement à la négociation de locale. Trois préfixes ne le font pas. /admin* est confié à updateSession pour que le cookie d'authentification Supabase soit rafraîchi ; /api* et /pitch* sont passés à next() sans être touchés, parce que ni l'un ni l'autre n'a de raison d'être redirigé vers une locale qu'il n'a jamais demandée. Le matcher lui-même exclut _next et tout ce qui contient un point, si bien que les ressources statiques ne réveillent jamais le middleware.
La dérivation qui se rentabilise est celle de la session, et le raisonnement se trouve dans un commentaire juste à côté : aucune page publique ne lit la session Supabase, donc exécuter updateSession sur le trafic public achèterait un aller-retour d'authentification par requête et rien d'autre. C'est un couplage délibéré plutôt qu'un oubli — le jour où une page publique aura besoin d'un utilisateur connecté, ce commentaire sera la première chose à revoir, et c'est exactement pour cela que c'est un commentaire et non un silence.
1,19 Mo de catalogues de messages, et le problème russe
Six catalogues JSON contiennent chaque chaîne du site. Ils ne font pas la même taille.
| Locale | Octets du catalogue | vs anglais |
|---|---|---|
| en | 170 069 | — |
| es | 186 854 | +10 % |
| pt | 187 552 | +10 % |
| de | 188 041 | +11 % |
| fr | 193 393 | +14 % |
| ru | 260 610 | +53 % |
Tailles des catalogues de messages mesurées avec wc -c. Total sur les six locales : 1 186 519 octets.
Quatre des cinq traductions se regroupent dix à quatorze pour cent au-dessus de l'anglais, ce qui correspond à peu près à l'expansion que toute traduction romane ou germanique vous offre gratuitement. Le russe se tient à +53 %, et il vaut la peine d'être précis sur le pourquoi, car ce chiffre flatte la mauvaise conclusion : une large part de cet écart relève de l'arithmétique UTF-8. Les caractères cyrilliques coûtent deux octets là où le latin en coûte un, si bien qu'une phrase russe de longueur identique atterrit au double d'octets avant qu'un seul mot supplémentaire ne soit ajouté. Le russe est aussi réellement plus bavard que l'anglais — mais le compte d'octets mesure l'encodage et la prose ensemble, et prétendre le contraire ferait mentir nos propres graphiques.
La taille compte à cause de la façon dont les catalogues sont chargés : la configuration de requête les importe en entier, à chaque requête. C'est un bon marché pour des chaînes d'interface et un très mauvais pour de la prose longue, et c'est pourquoi l'article que vous lisez ne vit pas du tout dans un catalogue de messages. Les pièces du Journal sont des modules de contenu typés sous src/content/articles/<slug>/<locale>.ts, importés uniquement par les routes d'articles. Du texte long dans les catalogues taxerait chaque page du site pour un texte qui n'apparaît que sur une seule.
Onze espaces de noms atteignent le navigateur
Les composants serveur traduisent sur le serveur ; les chaînes ne quittent jamais le rendu. Les composants client ne peuvent pas faire cela, et le correctif naïf — remettre tout le catalogue à NextIntlClientProvider — sérialisait plus de 10 ko de chaînes qu'aucun composant client de la page n'irait jamais lire, dans la charge RSC de chaque page.
Le provider reçoit donc un sous-ensemble choisi à la main. Un utilitaire pickMessages résout des chemins pointés dans le catalogue, et une seule liste GLOBAL_CLIENT_NAMESPACES — onze entrées — décide de ce qu'un navigateur a le droit de voir. Certaines de ces entrées ne sont pas des espaces de noms entiers mais des feuilles individuelles : seules trois feuilles de pages.services partent, parce que trois d'entre elles sont nécessaires à un composant interactif et que le reste de cet espace de noms est rendu sur le serveur. La procédure de maintenance qui garde cette liste honnête est un grep, documenté dans le fichier à côté de la liste, ce qui est la forme de documentation la moins glorieuse et la plus fiable que nous connaissions.
Chaque chaîne qui atteint le navigateur y est parce que quelqu'un a décidé qu'elle devait y être. Il n'existe pas de valeur par défaut « tout envoyer ».
L'exception est le Labo. Les composants de jeu sont chargés de texte d'une façon que les pages marketing ne sont pas — l'espace de noms games atteint environ 150 clés pour un seul jeu — et rien de tout cela n'a sa place dans l'autorisation globale. Le sous-arbre des jeux monte donc son propre provider imbriqué, portant son propre espace de noms, et c'est là que vit un piège franchement méchant.
Des traductions qui changent sans déploiement
Les catalogues statiques sont le plancher, pas le plafond. Le texte des surfaces pilotées par le CMS peut être surchargé depuis la base de données, si bien qu'une correction de formulation en espagnol n'exige pas de build. La fusion est délibérément prudente : les surcharges sont appliquées par setNestedValue sur un structuredClone du catalogue importé, jamais sur l'objet de module lui-même — cet objet est partagé par toutes les requêtes du processus, et le muter ferait de la surcharge d'un visiteur celle de tout le monde. La lecture se cache derrière unstable_cache, étiquetée i18n-overrides, avec une fenêtre de cinq minutes, et les écritures d'administration cassent l'étiquette plutôt que d'attendre son expiration.
Si la base de données est injoignable, un catch nu retombe sur le JSON statique et la page se rend. C'est le bon comportement, et c'est aussi ce qui a produit le meilleur bug de l'histoire de ce système.
Appeler cookies() à l'intérieur d'unstable_cache lève une exception dans Next 15+. Le lecteur de surcharges mis en cache faisait exactement cela. L'exception atterrissait dans le catch nu, le catalogue statique était servi, chaque page avait l'air parfaite — et la fonctionnalité de surcharge était morte en silence.
Les surcharges s'enregistraient bien. Elles se mettaient bien en cache. Elles n'apparaissaient simplement jamais, et rien nulle part ne signalait d'erreur, parce que le repli faisait précisément le travail pour lequel il avait été écrit. Le correctif est petit — la lecture des surcharges utilise le client Supabase public sans cookies, qui n'a de toute façon aucune raison de lire des cookies — mais la leçon lui a survécu. Nous traitons désormais un repli silencieux comme un repli inachevé : si un catch avale un échec qui change ce que l'utilisateur voit, il doit laisser une trace quelque part, sans quoi ce n'est pas un filet de sécurité, c'est un bandeau sur les yeux.
Un repli incapable de vous dire qu'il s'est déclenché finira par cacher une fonctionnalité morte.
Une conséquence des surcharges remonte jusqu'à la charge client : le provider est alimenté par getMessages() plutôt que par un import JSON brut. Importer le JSON directement serait marginalement plus rapide et contournerait entièrement la fusion, si bien que les composants client afficheraient le texte d'avant surcharge pendant que la moitié rendue côté serveur de la même page montrerait la copie corrigée. Deux sources de vérité sur un même écran, c'est pire qu'une source lente.
Replis, hreflang, et ce que reçoivent les robots
La couverture de traduction n'est jamais uniforme. Une section CMS peut exister en anglais et en allemand mais pas encore en portugais, et les options honnêtes sont une zone vide ou du texte anglais. Nous avons choisi l'anglais, au niveau du contenu : fetchSectionWithFallback, fetchRowsWithFallback et fetchPageMeta tentent chacune la locale demandée, réessaient en anglais en cas d'absence, et court-circuitent la reprise quand la locale demandée est déjà l'anglais. Une page partiellement traduite se dégrade en page bilingue plutôt qu'en page cassée.
La carte des alternatives ne sait rien de tout cela, et c'est voulu. alternates.ts déclare l'ensemble d'URL — six entrées hreflang plus x-default pointant sur l'URL anglaise — et rien d'autre. Y mêler la logique de repli voudrait dire que le graphe hreflang change de forme selon l'avancement d'un traducteur, ce qui est un excellent moyen d'apprendre à un moteur de recherche que votre structure d'URL est instable. La couverture s'exprime là où elle doit : le sitemap restreint les alternatives hreflang des pages de détail de réalisations et de journal aux locales qui ont réellement une entrée publiée.
Les slugs localisés ont aussi une conséquence à la frontière des robots que personne ne prévoit la première fois. Le parcours de devis est interdit dans robots.txt — c'est un entonnoir, pas une page d'atterrissage — et comme le slug est localisé, il n'y a pas un seul chemin à interdire. Il y en a six. Ajoutez que les groupes de robots.txt sont indépendants les uns des autres (une interdiction écrite dans un groupe ne s'applique pas au suivant), et que nous autorisons dix-huit robots d'IA dans dix-huit groupes distincts, et la liste d'interdictions doit être répétée intégralement, à chaque fois, par chaque groupe. Elle est générée, pas tapée, pour des raisons qui devraient sauter aux yeux.
La multiplication se voit aussi dans le sitemap. Vingt pages statiques deviennent 120 entrées avant qu'un seul jeu, cas client ou article ne soit compté, et chacune de ces entrées porte son propre bloc d'alternatives. Un sitemap de cette forme n'est pas quelque chose que l'on maintient à la main ; c'est une fonction de la carte des chemins, et c'est la même raison pour laquelle la carte des chemins a le droit d'être le seul endroit où un slug est écrit. Tout ce qui doit être répété à six endroits finira par être répété à cinq.
Le dernier kilomètre, ce sont les métadonnées sociales, où six locales deviennent une carte Open Graph — en_US, ru_RU, es_ES, de_DE, fr_FR, pt_PT — émise par un utilitaire partagé qui redéclare toujours chaque champ. Cette dernière partie n'est pas une question de style : Next.js remplace openGraph par segment de route au lieu de le fusionner en profondeur, si bien que toute page qui définit l'un de ses propres champs perd silencieusement les valeurs par défaut du layout de locale, emportant og:site_name et og:locale avec elles. L'utilitaire existe pour que personne n'ait à s'en souvenir.
Rien de tout cela n'est exotique. Ce qui fait tenir l'ensemble, c'est que chacune des six locales est traitée comme une surface de premier rang avec les mêmes garanties — sa propre URL, son propre catalogue, ses propres métadonnées, sa propre histoire côté robots — plutôt que comme de l'anglais avec une table de correspondance devant. La mesure de cela n'est pas le lancement ; c'est le coût de la vingt-neuvième entrée de chemin : une ligne dans la carte, six slugs, une ligne de sitemap, et aucune discussion. Nous avons écrit pourquoi nous tenons cette exigence dans Le standard de l'enchantement ; voici à quoi elle ressemble quand elle prend la forme d'une table de routage.