Aller au contenu
18 octobre 202611 min readGuides

ICU et MessageFormat 2 dans i18next : arguments typés, linting et App Router

i18next écrit les messages dans son propre format ({{name}}, clés à suffixe comme photos_one), et un plugin i18nFormat peut le remplacer par une autre syntaxe : Fluent, polyglot, le format Shopify, et les deux dont parle cet article, ICU MessageFormat avec i18next-icu et Unicode MessageFormat 2 (MF2) avec i18next-mf2. Le format i18next reste celui par défaut, et rien ne change pour les projets qui l’utilisent. Mais si vous écrivez en ICU ou en MF2, vous tombiez sur des lacunes : des messages qui échouaient sans un mot, des options de t() que TypeScript ne pouvait pas vérifier, un CLI qui créait des clés _one et _other que personne ne lit, et un wrapper écrit à la main pour faire entrer ICU dans l’App Router de Next.js.

Nous avons comblé ces lacunes cette semaine, dans six paquets. Cet article passe en revue ce qui a changé, avec du code que nous avons exécuté contre les versions publiées. La référence pour tous les détails est la page de documentation i18next Using ICU or MF2.

Les versions (17 octobre 2026)
  • i18next 26.5 : arguments ICU et MF2 vérifiés par TypeScript avec parseInterpolation: 'icu' ou 'mf2' ; 26.5.1 accélère la vérification de types des grands namespaces, 26.5.2 fait signaler une option erronée par TypeScript 7 aussi clairement que par TypeScript 6.
  • i18next-icu 2.6 et i18next-mf2 2.1 : des avertissements de debug quand de la syntaxe i18next se retrouve dans des messages ICU ou MF2 ; 2.6.1 et 2.1.1 recompilent les messages qui changent à l’exécution.
  • i18next-cli 1.76 et 1.77 : un mode ICU et MF2 pour extract, lint, status, instrument et types.
  • react-i18next 17.0.21 et 17.1 : IcuTransWithoutContext pour les Server Components, des values d’IcuTrans typées.
  • next-i18next 16.4 : les providers next-i18next/icu et next-i18next/mf2 pour l’App Router.

Choisir la syntaxe

Le même message au format i18next, en ICU et en MF2 :

{
  "photos_one": "You have one photo.",
  "photos_other": "You have {{count}} photos."
}
{
  "photos": "You have {count, plural, one {one photo} other {# photos}}."
}
{
  "photos": ".input {$count :number} .match $count one {{You have one photo.}} * {{You have {$count} photos.}}"
}

Le premier est le format i18next : une clé par forme de pluriel, résolue par i18next. Le deuxième est en ICU, le troisième en MF2 : une seule clé, et le pluriel vit dans le message. Dans les trois cas, vous appelez t('photos', { count: 3 }).

  • Le format i18next est celui par défaut. Gardez-le si vous partez de zéro, utilisez l’outillage i18next partout et aimez le nesting et le formatage intégré.
  • ICU est la syntaxe de react-intl, next-intl, Lingui et FormatJS, et de nombreux outils de traduction. Choisissez-la si vos messages sont déjà en ICU, si vous passez de l’une de ces bibliothèques à i18next, ou si vos traducteurs l’attendent. Les arguments, plural, select, selectordinal et les formats de nombres et de dates sont repris tels quels ; les balises de texte enrichi nommées comme <b> deviennent des balises numérotées comme <0> pour les composants React.
  • MF2 est le successeur Unicode d’ICU MessageFormat. Sa syntaxe est plus explicite (.input, .match, variantes), ce qui paie pour les messages qui sélectionnent sur plus d’une valeur. Plus d’informations sur son état plus bas.

La mise en place tient en un plugin par instance i18next :

import i18next from 'i18next'
import ICU from 'i18next-icu' // npm install i18next i18next-icu intl-messageformat

await i18next.use(ICU).init({
  lng: 'en',
  resources: {
    en: {
      translation: {
        greeting: 'Hello {name}!',
        photos: 'You have {count, plural, =0 {no photos} one {one photo} other {# photos}}.',
        order: '{status, select, shipped {Your order is on its way.} other {Your order is being prepared.}}'
      }
    }
  }
})

i18next.t('greeting', { name: 'Ana' }) // -> 'Hello Ana!'
i18next.t('photos', { count: 1250 }) // -> 'You have 1,250 photos.'
i18next.t('order', { status: 'shipped' }) // -> 'Your order is on its way.'

Pour MF2, installez i18next-mf2, enregistrez MF2 à la place et écrivez {$name}. Mettez i18nFormat: { bidiIsolation: 'none' }, sauf si vous voulez les marques d’isolation bidi invisibles de MF2 autour de chaque placeholder.

Ce qui change quand vous passez à ICU ou MF2

i18next-icu et i18next-mf2 n’étendent pas le traitement des messages d’i18next, ils le remplacent. Tout ce qui entoure le message continue de fonctionner : détection de langue, fallbacks, namespaces, backends, saveMissing, keyPrefix, defaultVariables. Ce qui relève de la syntaxe de message propre à i18next cesse de fonctionner :

  • Les clés à suffixe de pluriel et de contexte (photos_one, friend_male) ne sont pas recherchées. Mettez un plural ou un select dans le message.
  • L’interpolation {{name}} n’est pas analysée. Écrivez {name} (ICU) ou {$name} (MF2).
  • Le nesting avec $t(key) est affiché comme du texte. Traduisez la clé interne et passez-la comme valeur.
  • interpolation.escapeValue n’est pas appliqué. React échappe de lui-même ; ailleurs, utilisez l’option escapeVariables: true d’i18next-icu ou échappez les valeurs vous-même.
  • Le formatage i18next et returnObjects ne s’appliquent pas non plus, et saveMissingPlurals envoie des clés à suffixe que les plugins ne lisent jamais, alors désactivez-le.

Rien de tout cela ne lève d’erreur. La page de documentation contient le tableau complet avec le remplacement de chaque fonctionnalité.

Des avertissements de debug quand les syntaxes se mélangent

Ce « rien ne lève d’erreur » était le piège le plus courant : un projet migré avec un {{name}} ou une clé _one oubliés affichait simplement le mauvais texte. Depuis i18next-icu 2.6.0 et i18next-mf2 2.1.0, les deux plugins vous le signalent quand i18next tourne avec debug: true :

await i18next.use(ICU).init({
  debug: true,
  lng: 'en',
  resources: {
    en: {
      translation: {
        greeting: 'Hello {{name}}!',
        photos: 'You have photos.',
        photos_one: 'You have one photo.',
        photos_other: 'You have {count} photos.',
        footer: 'Read our $t(terms)',
        terms: 'terms'
      }
    }
  }
})

i18next.t('greeting', { name: 'Ana' })
i18next.t('photos', { count: 3 })
i18next.t('footer')
i18next: i18next-icu: key "greeting" (en/translation) could not be parsed (MALFORMED_ARGUMENT). It contains "{{": i18next interpolation like {{name}} does not work with ICU, use {name}. See https://www.i18next.com/how-to/using-icu-or-mf2
i18next: i18next-icu: key "photos" (en/translation) was called with count, but i18next-icu does not look up plural suffix keys (photos_one, photos_other). Put the plural into the message: {count, plural, one {# item} other {# items}}. See https://www.i18next.com/how-to/using-icu-or-mf2
i18next: i18next-icu: key "footer" (en/translation) contains "$t(", but i18next-icu does not support nesting, the text is output as is. See https://www.i18next.com/how-to/using-icu-or-mf2

Chaque avertissement apparaît une fois par clé, plus une fois par instance quand escapeValue est actif mais non appliqué. Sans debug, rien de tout cela ne tourne et rien ne change.

Arguments ICU et MF2 typés

Le support TypeScript d’i18next vérifie les arguments {{name}} en les lisant dans le type du message. Les utilisateurs d’ICU et de MF2 devaient le désactiver avec parseInterpolation: false et perdaient la vérification des arguments. Depuis i18next 26.5.0, l’option lit ICU ou MF2 à la place :

// i18next.d.ts
import 'i18next'
import type Resources from './resources' // generated by i18next-cli

declare module 'i18next' {
  interface CustomTypeOptions {
    parseInterpolation: 'icu' // or 'mf2'
    resources: Resources
  }
}

Avec photos: 'You have {count, plural, one {# photo} other {# photos}} in {album}.' :

i18next.t('photos', { count: 3, album: 'Summer' }) // ok
i18next.t('photos', { count: 3, albm: 'Summer' })
i18next.t('photos', { count: 'three', album: 'Summer' })
i18next.t('photos')

tsc rejette les trois derniers appels. Les lignes utiles de chaque erreur :

app.ts(4,11): error TS2345: Argument of type '["photos", { count: 3; albm: "Summer"; }]' is not assignable to parameter of type ...
          Property 'album' is missing in type '{ count: 3; albm: "Summer"; }' but required in type '{ count: number; album: string | number; }'.

app.ts(5,11): error TS2345: Argument of type '["photos", { count: string; album: string; }]' is not assignable to parameter of type ...
            Types of property 'count' are incompatible.
              Type 'string' is not assignable to type 'number'.

app.ts(6,11): error TS2345: Argument of type '["photos"]' is not assignable to parameter of type '[key: ("photos" | "photos"[]) & { 'pass these arguments as t() options': { count: number; album: string | number; }; }] | ...

Un argument plural prend un nombre, {album} une chaîne ou un nombre, un select ses clés de cas ou n’importe quelle chaîne, {d, date} une Date. Les arguments imbriqués dans les cas de plural et de select comptent aussi ; # et les balises non. MF2 fonctionne de la même façon, avec des types tirés de :number, :date et des autres fonctions. La sortie ci-dessus vient de TypeScript 6 ; depuis i18next 26.5.2, TypeScript 7 signale les mêmes erreurs.

La même vérification couvre react-i18next. Depuis react-i18next 17.1.0, les values d’IcuTrans sont typées à partir du message de son i18nKey :

<IcuTrans
  i18nKey="welcome" // 'Welcome back, <0>{name}</0>!'
  defaultTranslation="Welcome back, <0>{name}</0>!"
  content={[{ type: 'strong' }]}
  values={{ nmae: 'Ada' }}
/>
// error TS2353: Object literal may only specify known properties, and 'nmae' does not exist in type '{ name: string | number; }'.

Les vérifications ont besoin des messages sous forme de types littéraux. Un import typeof d’un fichier JSON élargit chaque message en string, et plus rien n’est vérifié. La commande types d’i18next-cli écrit un fichier de ressources avec des messages littéraux, et depuis 1.77.0 elle met aussi parseInterpolation: 'icu' ou 'mf2' dans un i18next.d.ts nouvellement généré quand votre i18next est en 26.5.0 ou plus.

Un autre changement TypeScript d’i18next 26.5.1 aide tout le monde, ICU ou non : la vérification de types de t() sur un grand namespace n’est plus quadratique en nombre de clés. Avec 5 000 clés dans un namespace et un appel par clé, la vérification est passée de 14,8 secondes à 1,1 seconde sous TypeScript 6.

i18next-cli : mode ICU et MF2

i18next-cli traitait jusqu’ici chaque projet comme du format i18next. Depuis 1.76.0, une option suffit à changer cela :

// i18next.config.ts
import { defineConfig } from 'i18next-cli'

export default defineConfig({
  locales: ['en', 'de', 'ru'],
  extract: {
    input: ['src/**/*.{ts,tsx}'],
    output: 'locales/{{language}}/{{namespace}}.json',
    messageFormat: 'icu' // or 'mf2'
  }
})

Avec elle, extract écrit une clé par message. t('photos', 'You have {count, plural, one {# photo} other {# photos}} in {album}.', { count, album }) donne photos, et non photos_one et photos_other (plus _few et _many pour le russe). IcuTrans et les enfants de Trans sont extraits avec des placeholders ICU ou MF2, status compte un message au pluriel comme une seule clé, et instrument écrit des messages ICU ou MF2.

lint lit chaque valeur dans chaque locale. Ici, la traduction russe a renommé un argument et n’a gardé que les catégories de pluriel anglaises :

{
  "photos": "У вас {count, plural, one {# фото} other {# фото}} в {albom}."
}
✖ Linter found 2 potential issues.

locales/ru/translation.json
  2: Error: ICU message: "photos: arguments differ from "en": missing {album}, unexpected {albom}"
  2: Error: ICU message: "photos: plural {count} is missing the "ru" categories few, many"

Il signale aussi les erreurs de syntaxe et les catégories qu’une locale n’utilise jamais (zero en anglais). Les sélecteurs exacts comptent quand ils couvrent une catégorie : =1 couvre one en anglais, mais pas en russe, où one désigne aussi 21, 31 et ainsi de suite.

React : IcuTrans, aussi dans les Server Components

Trans construit son message à partir des enfants JSX, et JSX réserve les accolades, donc les pluriels ICU ne peuvent pas s’y écrire. IcuTrans prend le message sous forme de chaîne et remplace les balises numérotées par les composants que vous déclarez dans content :

'use client'
import { IcuTrans } from 'react-i18next'

export function Rendered() {
  return (
    <IcuTrans
      i18nKey="rendered"
      defaultTranslation="Rendered by a <0>{where}</0> Component."
      content={[{ type: 'em' }]}
      values={{ where: 'Client' }}
    />
  )
}

Dans un Server Component, importer depuis react-i18next pouvait faire échouer le build avec createContext is not a function, parce que le point d’entrée principal crée un contexte React. Depuis react-i18next 17.0.21, importez IcuTransWithoutContext depuis son propre point d’entrée et passez t et i18n :

import { IcuTransWithoutContext } from 'react-i18next/IcuTransWithoutContext'

La section suivante le montre dans une page Next.js. Pluriels, selects et formats vivent tous dans le message. La macro Babel icu.macro est dépréciée au profit d’IcuTrans.

App Router de Next.js : next-i18next/icu et next-i18next/mf2

next-i18next 16 prend en charge l’App Router (et toujours le Pages Router, voir l’article sur la v16). ICU y demandait jusqu’ici une étape de plus : un Server Component ne peut pas passer une classe de plugin à un Client Component, donc <I18nProvider use={[ICU]}> dans un layout échouait avec "Functions cannot be passed directly to Client Components", et vous écriviez votre propre wrapper 'use client'. Depuis next-i18next 16.4.0, vous importez le provider depuis next-i18next/icu (ou next-i18next/mf2) à la place :

// i18n.config.ts
import type { I18nConfig } from 'next-i18next/proxy'
import ICU from 'i18next-icu'

const i18nConfig: I18nConfig = {
  supportedLngs: ['en', 'de'],
  fallbackLng: 'en',
  defaultNS: 'common',
  ns: ['common'],
  resourceLoader: (language, namespace) => import(`./app/i18n/locales/${language}/${namespace}.json`),
  use: [ICU],
}

export default i18nConfig
// app/[lng]/layout.tsx
import { initServerI18next, getT, getResources, generateI18nStaticParams } from 'next-i18next/server'
import { I18nProvider } from 'next-i18next/icu' // or 'next-i18next/mf2'
import i18nConfig from '../../i18n.config'

initServerI18next(i18nConfig)

export async function generateStaticParams() {
  return generateI18nStaticParams()
}

export default async function RootLayout({ children, params }: {
  children: React.ReactNode
  params: Promise<{ lng: string }>
}) {
  const { lng } = await params
  const { i18n } = await getT()

  return (
    <html lang={lng}>
      <body>
        <I18nProvider language={lng} resources={getResources(i18n)}>
          {children}
        </I18nProvider>
      </body>
    </html>
  )
}

getT dans les Server Components et useT dans les Client Components formatent alors les messages ICU, et le texte enrichi dans un Server Component utilise IcuTransWithoutContext :

// app/[lng]/page.tsx
import { getT } from 'next-i18next/server'
import { IcuTransWithoutContext } from 'react-i18next/IcuTransWithoutContext'

export default async function Page() {
  const { t, i18n } = await getT()

  return (
    <main>
      <p>{t('photos', { count: 5 })}</p>
      <IcuTransWithoutContext
        i18nKey="welcome"
        defaultTranslation="Welcome back, <0>{name}</0>!"
        content={[{ type: 'strong' }]}
        values={{ name: 'Ada' }}
        t={t}
        i18n={i18n}
      />
    </main>
  )
}

Les deux plugins sont des peer dependencies optionnelles de next-i18next, donc les apps qui n’importent jamais ces points d’entrée ne les embarquent pas. La version complète, avec un Client Component, des arguments typés et une traduction allemande, est l’exemple app-router-icu ; les détails sont dans la section du README ICU and MF2 with the App Router.

MessageFormat 2 et i18next-mf2 aujourd’hui

i18next-mf2 2.0 est passé d’une préversion figée à messageformat 4.1, l’implémentation stable de MF2 (LDML 48.2). :number, :integer, :currency et :percent fonctionnent d’emblée, :date, :datetime, :time et :unit sont des fonctions draft derrière mf2DraftFunctions: true, et la nouvelle option bidiIsolation désactive les marques d’isolation. Il faut Node.js 20.19, 22.12 ou 24 et plus. Tout ce qui précède (avertissements de debug, arguments typés, mode CLI, provider next-i18next) fonctionne aussi pour MF2.

MF2 excelle là où ICU est maladroit : un message qui sélectionne sur deux valeurs, comme le genre et le nombre, est une liste plate de variantes au lieu d’un select imbriqué dans un plural. Il a aussi une vraie syntaxe d’échappement au lieu des règles d’apostrophe d’ICU.

Pour être honnête sur la maturité : la spécification est stable, l’implémentation JavaScript est solide, et i18next-mf2 a des tests et une CI sur chaque version de Node.js prise en charge. Mais l’écosystème est jeune. Il existe peu de retours publics sur MF2 en production, la proposition TC39 pour Intl.MessageFormat est toujours au Stage 1, et la plupart des éditeurs de traduction et des linters connaissent ICU, pas MF2. Si vous démarrez un nouveau projet et que la syntaxe vous plaît, MF2 est un choix raisonnable. Si vous avez des messages ICU aujourd’hui, rien ne vous oblige à migrer. Notre article sur l’état de MF2 dans i18next en donne la version longue.

Petites corrections à connaître

  • Les messages modifiés sont pris en compte. Les deux plugins mettent en cache les messages compilés. Jusqu’à i18next-icu 2.6.1 et i18next-mf2 2.1.1, un message modifié après sa première utilisation (addResource, reloadResources, un reloadInterval de backend, le hot reload, le reloadOnPrerender de next-i18next) continuait de renvoyer l’ancien texte, sauf si vous aviez mis bindI18nStore. Le cache compare désormais le message.
  • replace fonctionne. t('key', { replace: { name } }) remplit les arguments ICU et MF2 comme i18next le fait pour les siens (mêmes versions).
  • React Native avec Hermes. Hermes n’a ni Intl.PluralRules ni Intl.Locale, donc les cas de pluriel ICU comme one et other y échouent (les cas exacts comme =0 fonctionnent toujours), et sans Intl.Locale, messageformat ne formate aucun message. Les README d’i18next-icu et d’i18next-mf2 indiquent désormais quels polyfills charger, et dans quel ordre.
  • Les values d’IcuTrans l’emportent sur defaultVariables, comme dans t() (react-i18next 17.0.21).

Si vos traductions sont dans Locize

Les messages MessageFormat ne valent que ce que valent leurs traductions, et la plupart de leurs bugs naissent pendant la traduction : un argument renommé, une catégorie de pluriel manquante, une accolade en moins. Dans un projet Locize au format MessageFormat (messageformat, souvent appelé ICU MessageFormat) :

  • L’éditeur met en évidence la syntaxe MessageFormat et affiche l’erreur d’analyse sous un message qui ne se parse pas.
  • Les contrôles signalent une traduction dont les noms d’arguments diffèrent de la source, y compris dans les cas de plural et de select (issue 241, et issue 242 pour les variables Fluent). Les contrôles linguistiques lisent MessageFormat comme une syntaxe et ne comparent que le cas other d’un pluriel, si bien qu’une traduction russe correcte avec plus de cas que sa source anglaise n’est pas signalée.
  • L’AI review juge un pluriel MessageFormat selon les catégories de pluriel de la langue cible : une proposition pour le russe doit avoir one, few, many et other, et une proposition qui ne fait que copier les one et other anglais est écartée.

Les messages MF2 sont stockés et livrés comme du texte, comme tout autre format. Dès que MF2 gagnera en popularité, Locize élargira sa prise en charge ; ce que nous prévoyons et les signaux que nous attendons sont dans l’article sur MF2. Si vous voulez voir ces contrôles sur vos propres messages, créez un projet gratuit et importez vos fichiers.

Questions fréquentes

i18next prend-il en charge ICU MessageFormat ? Oui, avec le plugin officiel i18next-icu, qui formate les messages avec intl-messageformat. Le format i18next reste celui par défaut, donc rien ne change tant que vous n’ajoutez pas le plugin. Les messages ICU de react-intl, next-intl ou Lingui gardent leurs arguments, pluriels, selects et formats, et avec debug: true, i18next-icu avertit de la syntaxe i18next qu’ICU ne lit pas.

Existe-t-il un plugin i18next pour MessageFormat 2 ? Oui : i18next-mf2, maintenu dans l’organisation i18next. La version 2 tourne sur messageformat 4.1, l’implémentation stable de MF2. Elle fonctionne, mais encore peu d’outils et d’éditeurs de traduction comprennent MF2.

TypeScript peut-il vérifier les arguments des messages ICU dans i18next ? Oui, depuis i18next 26.5.0 : mettez parseInterpolation: 'icu' (ou 'mf2') et utilisez le fichier de ressources de i18next-cli types. Les arguments manquants, mal orthographiés ou du mauvais type sont des erreurs de type, et avec react-i18next 17.1.0, les values erronées d’IcuTrans aussi.

next-i18next fonctionne-t-il avec l’App Router et ICU ? Oui. next-i18next 16 prend en charge l’App Router et le Pages Router, et depuis 16.4.0, next-i18next/icu et next-i18next/mf2 fournissent un I18nProvider avec le plugin enregistré, donc aucun wrapper 'use client' n’est nécessaire.

i18next-cli peut-il extraire et vérifier des messages ICU ? Oui, depuis 1.76.0 avec extract.messageFormat: 'icu' ou 'mf2' : une clé par message, lint pour la syntaxe, les arguments et les catégories de pluriel par locale, et les messages au pluriel comptés comme une seule clé dans status.

Dois-je utiliser le format i18next, ICU ou MessageFormat 2 ? Le format i18next si vous partez de zéro avec l’outillage i18next, ICU si vos messages ou vos traducteurs le parlent déjà, MF2 si vous voulez le nouveau standard et acceptez un écosystème jeune.

Fatigué de gérer vos traductions à la main ?

Locize est le backend de gestion de traductions créé par l'équipe i18next : diffusion CDN, traduction IA, édition in-context, sans redéploiement.

Démarrez votre essai gratuit de 14 jours