Skip to content
October 18, 202610 min readGuides

ICU and MessageFormat 2 in i18next: typed arguments, linting and the App Router

i18next writes messages in its own format ({{name}}, suffix keys like photos_one), and an i18nFormat plugin can swap in another syntax: Fluent, polyglot, the Shopify format, and the two this post is about, ICU MessageFormat through i18next-icu and Unicode MessageFormat 2 (MF2) through i18next-mf2. The i18next format stays the default, and nothing changes for projects that use it. But if you write ICU or MF2, you used to run into gaps: messages that failed without a word, t() options TypeScript could not check, a CLI that created _one and _other keys nobody reads, and a hand-written wrapper to get ICU into the Next.js App Router.

We closed those gaps this week, across six packages. This post walks through what changed, with code we ran against the released versions. The reference for all details is the i18next docs page Using ICU or MF2.

The releases (October 17, 2026)
  • i18next 26.5: type-checked ICU and MF2 arguments with parseInterpolation: 'icu' or 'mf2'; 26.5.1 makes type-checking large namespaces fast, 26.5.2 makes TypeScript 7 report a wrong option as clearly as TypeScript 6.
  • i18next-icu 2.6 and i18next-mf2 2.1: debug warnings when i18next syntax ends up in ICU or MF2 messages; 2.6.1 and 2.1.1 recompile messages that change at runtime.
  • i18next-cli 1.76 and 1.77: an ICU and MF2 mode for extract, lint, status, instrument and types.
  • react-i18next 17.0.21 and 17.1: IcuTransWithoutContext for Server Components, typed IcuTrans values.
  • next-i18next 16.4: next-i18next/icu and next-i18next/mf2 providers for the App Router.

Choosing the syntax

The same message in the i18next format, ICU and 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.}}"
}

The first is the i18next format: one key per plural form, resolved by i18next. The second is ICU, the third MF2: one key, and the plural lives inside the message. In all three cases you call t('photos', { count: 3 }).

  • The i18next format is the default. Keep it if you start fresh, use i18next tooling throughout and like nesting and the built-in formatting.
  • ICU is the syntax of react-intl, next-intl, Lingui and FormatJS, and of many translation tools. Pick it if your messages already are ICU, if you are moving from one of those libraries to i18next, or if your translators expect it. Arguments, plural, select, selectordinal and number and date formats carry over as they are; named rich-text tags like <b> become numbered tags like <0> for the React components.
  • MF2 is the Unicode successor of ICU MessageFormat. Its syntax is more explicit (.input, .match, variants), which pays off for messages that select on more than one value. More on its state below.

Setup is one plugin per i18next instance:

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.'

For MF2, install i18next-mf2, register MF2 instead and write {$name}. Set i18nFormat: { bidiIsolation: 'none' } unless you want MF2's invisible bidi isolation marks around every placeholder.

What changes when you switch

i18next-icu and i18next-mf2 do not extend i18next's message handling, they replace it. Everything around the message keeps working: language detection, fallbacks, namespaces, backends, saveMissing, keyPrefix, defaultVariables. What belongs to i18next's own message syntax stops working:

  • Plural and context suffix keys (photos_one, friend_male) are not looked up. Put a plural or select into the message.
  • {{name}} interpolation is not parsed. Write {name} (ICU) or {$name} (MF2).
  • Nesting with $t(key) is output as text. Translate the inner key and pass it as a value.
  • interpolation.escapeValue is not applied. React escapes on its own; elsewhere use i18next-icu's escapeVariables: true or escape values yourself.
  • i18next formatting and returnObjects do not apply either, and saveMissingPlurals sends suffix keys the plugins never read, so turn it off.

None of these throws. The docs page has the full table with the replacement for each.

Debug warnings when syntaxes are mixed

That "none of these throws" was the most common trap: a migrated project with a leftover {{name}} or _one key simply rendered the wrong text. Since i18next-icu 2.6.0 and i18next-mf2 2.1.0, both plugins tell you when i18next runs with 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

Each warning comes once per key, plus once per instance when escapeValue is on but not applied. Without debug, nothing runs and nothing changes.

Typed ICU and MF2 arguments

i18next's TypeScript support checks {{name}} arguments by reading them out of the message type. ICU and MF2 users had to turn that off with parseInterpolation: false and lost argument checking. Since i18next 26.5.0, the option reads ICU or MF2 instead:

// 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
  }
}

With 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 rejects the last three calls. The relevant lines of each error:

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; }; }] | ...

A plural argument takes a number, {album} takes a string or number, a select takes its case keys or any string, {d, date} takes a Date. Arguments nested inside plural and select cases count too; # and tags do not. MF2 works the same way, with types from :number, :date and the other functions. The output above is from TypeScript 6; since i18next 26.5.2, TypeScript 7 reports the same errors.

The same check covers react-i18next. Since react-i18next 17.1.0, the values of IcuTrans are typed from the message of its 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; }'.

The checks need the messages as literal types. A typeof import of a JSON file widens every message to string, and then nothing is checked. The i18next-cli types command writes a resources file with literal messages, and since 1.77.0 it also sets parseInterpolation: 'icu' or 'mf2' in a newly generated i18next.d.ts when your i18next is 26.5.0 or newer.

One more TypeScript change in i18next 26.5.1 helps everybody, ICU or not: type-checking t() against a large namespace is no longer quadratic in the number of keys. With 5,000 keys in one namespace and one call per key, the check went from 14.8 seconds to 1.1 seconds on TypeScript 6.

i18next-cli: ICU and MF2 mode

i18next-cli used to treat every project as i18next format. Since 1.76.0, one option switches it:

// 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'
  }
})

With it, extract writes one key per message. t('photos', 'You have {count, plural, one {# photo} other {# photos}} in {album}.', { count, album }) gives photos, not photos_one and photos_other (plus _few and _many for Russian). IcuTrans and the Trans children are extracted with ICU or MF2 placeholders, status counts a plural message as one key, and instrument writes ICU or MF2 messages.

lint reads every value in every locale. Here the Russian translation renamed an argument and kept only the English plural categories:

{
  "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"

It also reports syntax errors and categories a locale never uses (zero in English). Exact selectors count when they cover a category: =1 covers one in English, but not in Russian, where one also means 21, 31 and so on.

React: IcuTrans, also in Server Components

Trans builds its message from JSX children, and JSX reserves curly braces, so ICU plurals can not be written there. IcuTrans takes the message as a string and replaces numbered tags with the components you declare in 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' }}
    />
  )
}

In a Server Component, importing from react-i18next could fail the build with createContext is not a function, because the main entry creates a React context. Since react-i18next 17.0.21, import IcuTransWithoutContext from its own entry point and pass t and i18n:

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

The next section shows it in a Next.js page. Plurals, selects and formats all live in the message. The icu.macro Babel macro is deprecated in favor of IcuTrans.

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

next-i18next 16 supports the App Router (and still the Pages Router, see the v16 post). ICU used to need one extra step there: a Server Component can not pass a plugin class to a Client Component, so <I18nProvider use={[ICU]}> in a layout failed with "Functions cannot be passed directly to Client Components", and you wrote your own 'use client' wrapper. Since next-i18next 16.4.0, you import the provider from next-i18next/icu (or next-i18next/mf2) instead:

// 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 in Server Components and useT in Client Components then format ICU messages, and rich text in a Server Component uses 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>
  )
}

Both plugins are optional peer dependencies of next-i18next, so apps that never import these entry points do not bundle them. The complete version, with a Client Component, typed arguments and a German translation, is the app-router-icu example; details are in the README section ICU and MF2 with the App Router.

MessageFormat 2: where i18next-mf2 stands

i18next-mf2 2.0 moved from a pinned prerelease to messageformat 4.1, the stable implementation of MF2 (LDML 48.2). :number, :integer, :currency and :percent work out of the box, :date, :datetime, :time and :unit are draft functions behind mf2DraftFunctions: true, and the new bidiIsolation option turns off the isolation marks. It needs Node.js 20.19, 22.12 or 24 and newer. Everything above (debug warnings, typed arguments, the CLI mode, the next-i18next provider) works for MF2 too.

MF2 is good at what ICU makes awkward: a message that selects on two values, like gender and count, is a flat list of variants instead of a select nested in a plural. It also has a proper escape syntax instead of ICU's apostrophe rules.

To be honest about maturity: the specification is stable, the JavaScript implementation is solid, and i18next-mf2 has tests and CI on every supported Node.js version. But the ecosystem is early. There are few public reports of MF2 in production so far, the TC39 proposal for Intl.MessageFormat is still at Stage 1, and most translation editors and linters know ICU, not MF2. If you start a new project and like the syntax, MF2 is a reasonable choice. If you have ICU messages today, there is no need to migrate. Our post on the state of MF2 in i18next has the longer version.

Smaller fixes worth knowing

  • Changed messages are picked up. Both plugins cache compiled messages. Until i18next-icu 2.6.1 and i18next-mf2 2.1.1, a message that changed after its first use (addResource, reloadResources, a backend reloadInterval, hot reload, next-i18next's reloadOnPrerender) kept returning the old text unless you set bindI18nStore. The cache now compares the message.
  • replace works. t('key', { replace: { name } }) fills ICU and MF2 arguments like it does i18next's own (same versions).
  • React Native with Hermes. Hermes has no Intl.PluralRules and no Intl.Locale, so ICU plural cases like one and other fail there (exact cases like =0 still work), and without Intl.Locale messageformat formats no message at all. The i18next-icu and i18next-mf2 READMEs now list which polyfills to load, in which order.
  • IcuTrans values win over defaultVariables, as they do in t() (react-i18next 17.0.21).

If your translations live in Locize

MessageFormat messages are only as good as their translations, and most of their bugs are introduced while translating: a renamed argument, a missing plural category, a brace too few. In a Locize project with the MessageFormat format (messageformat, often called ICU MessageFormat):

  • The editor highlights the MessageFormat syntax and shows the parse error under a message that does not parse.
  • The checks flag a translation whose argument names differ from the source, also inside plural and select cases (issue 241, and issue 242 for Fluent variables). The linguistic checks read MessageFormat as syntax and compare only the other case of a plural, so a correct Russian translation with more cases than its English source is not flagged.
  • AI review judges a MessageFormat plural by the target language's plural categories: a proposal for Russian needs one, few, many and other, and one that only copies the English one and other is discarded.

MF2 messages are stored and delivered as text, like any other format. As soon as MF2 gets more traction, Locize will add more support for it; what we plan and which signals we wait for is in the MF2 post. If you want to see these checks on your own messages, create a free project and import your files.

Frequently asked questions

Does i18next support ICU MessageFormat? Yes, with the official i18next-icu plugin, which formats messages with intl-messageformat. The i18next format stays the default, so nothing changes until you add the plugin. ICU messages from react-intl, next-intl or Lingui keep their arguments, plurals, selects and formats, and with debug: true i18next-icu warns about i18next syntax that ICU does not read.

Is there an i18next plugin for MessageFormat 2? Yes: i18next-mf2, maintained in the i18next organization. Version 2 runs on messageformat 4.1, the stable MF2 implementation. It works, but fewer tools and translation editors understand MF2 yet.

Can TypeScript check the arguments of ICU messages in i18next? Yes, since i18next 26.5.0: set parseInterpolation: 'icu' (or 'mf2') and use the resources file from i18next-cli types. Missing, misspelled or wrongly typed arguments are type errors, and with react-i18next 17.1.0 so are wrong IcuTrans values.

Does next-i18next work with the App Router and ICU? Yes. next-i18next 16 supports the App Router and the Pages Router, and since 16.4.0 next-i18next/icu and next-i18next/mf2 provide an I18nProvider with the plugin registered, so no 'use client' wrapper is needed.

Can i18next-cli extract and lint ICU messages? Yes, since 1.76.0 with extract.messageFormat: 'icu' or 'mf2': one key per message, lint for syntax, arguments and plural categories per locale, and plural messages counted as one key in status.

Should I use the i18next format, ICU or MessageFormat 2? The i18next format if you start fresh with i18next tooling, ICU if your messages or translators already speak it, MF2 if you want the new standard and accept an early ecosystem.

Tired of managing translations by hand?

Locize is the translation management backend by the i18next team: CDN delivery, AI translation, in-context editing, no redeploys.

Start your free 14-day trial