react-brain
Browse decisions
React & language foundations 3App architecture 11UI 13Platform & native 9Build, test, observe, secure 6AI in React apps 3stack composerdecision recordscensusstaleness benchnewsletterarchitecturechangelogmethodologyroadmap

entriesui · verified 2026-09-24 · react + react-native

Internationalization (i18n)

reviewedconfidence: mediumthis tier holds 71% on the public scorecard (7/20 graded · 0 overturned) →

re-verified 5× — 2026-09-24 · 2026-09-01 · 2026-07-16 · 2026-07-13 · 2026-07-06 · changelog

recommendation

Next.js app → next-intl. Outside Next.js (web or React Native) → i18next (web + RN, huge ecosystem); Lingui instead when compile-time extraction and bundle size outweigh plugin reach.

  • bundle-size-sensitive Next.js app → next-intl (AOT-compiled ICU messages)
  • bundle-size-sensitive outside Next.js → Lingui (compile-time extraction)
  • need maximum plugin ecosystem / RN + web parity → i18next
  • already on react-intl, or ICU messages shared with non-React code → FormatJS / react-intl (runtime ICU; mature)
  • inline translations without keys, React 19 on Vite or Next.js 16+ → fbtee (5.x; a Babel-dependent pipeline stays on 3.3.0)

Options & tradeoffs

the field considered — and why each one isn’t the default here

optiontradeoffevidence
next-intl (4.x)Next.js-first; since v4.8 compiles ICU messages ahead of time into minified ASTs run by a ~650-byte runtime, shrinking bundles. 4.14.7 (2026-09-24, verified vs npm).7.9M/wk
FormatJS / react-intlICU message format at runtime; mature, framework-agnostic, the canonical ICU reference. react-intl 12.1.2 (2026-09-19, verified vs npm), peer React >=18.3.8M/wk · ships in 4/34
Lingui (6.x)Compile-time message extraction; low-boilerplate; i18n directives. @lingui/core 6.8.0 (2026-09-23, verified vs npm).ships in 3/34
i18next / react-i18nextVery popular, big plugin ecosystem; works web + RN. i18next 26.4.2 / react-i18next 17.0.15 (2026-09, verified vs npm; react-i18next peers i18next >=26.2).20.7M/wk · ships in 8/34
fbteeModern continuation of Meta's deprecated fbt: inline translations, no keys or t() wrappers, a native Rust/Oxc compiler (no Babel since 4.0). 5.0.0 (2026-09-16, verified vs npm), peer React ^19; see note: FBTEE PIPELINE.

evidence: npm weekly downloads (signals snapshot) · “ships in n/D” = adoption across the production-app census, honest denominators

npm weekly downloads (from the corpus's last signals run): next-intl 7.9M · react-i18next 20.7M · i18next 27.8M · react-intl 3.8M

Verified notes

Separate the message FORMAT from the MACHINERY. The format is ICU (interpolation, plurals, select, number/date skeletons), a stable Unicode standard shared by react-intl, next-intl and Lingui, so the syntax investment survives a library change. The machinery either ships a parser to every device (runtime) or compiles messages away at build time (next-intl's AST precompilation, Lingui's extraction) — a bundle-size decision. The pick is framework-first: in Next.js, next-intl; elsewhere i18next for reach and web + RN parity, Lingui for compile-time bundles. FBTEE PIPELINE (2026-09-24, verified vs npm + README): 4.0 (2026-08-29, GitHub release) removed the Babel compiler — @nkzw/babel-plugin-fbtee stops at 3.3.0, so a Babel-dependent workflow stays on fbtee 3.3.0. 5.0.0 (2026-09-16) merged @nkzw/fbtee-cli and @nkzw/oxc-transform-fbtee (both now deprecated on npm) into @nkzw/fbtee-compiler, a devDependency that carries the fbtee command; runtime imports stay on fbtee. Integrations: @nkzw/vite-plugin-fbtee 5 (vite >=6.3) and @nkzw/next-plugin-fbtee 5 (next >=16). Upgrading from 4 means installing the compiler package and bumping the plugins to v5; commands are unchanged. BROWSER TRANSLATORS (2026-09-24, reading 'What browser translators do to a live DOM'): Chrome's translator, the Google bundle and Yandex detach React's text nodes, so updates freeze or removeChild/insertBefore throw; the common crash guard hides the errors but leaves stale UI. translate="no" on the element held on every engine tested; class="notranslate" did not → this is a web production concern no i18n library covers. Edge and Firefox rewrite nodes in place and do not break this way; Safari was unmeasured. HISTORY: 2026-09-24 CORRECTED: fbtee row described 4.0 as current; 5.0.0 (2026-09-16) consolidated the compiler into @nkzw/fbtee-compiler; detect token formatjs given a when-clause. 2026-09-24 GAP CLOSED: browser-translator DOM-detach crash class (reading, React Digest #2370). 2026-09-01 GAP CLOSED: fbtee 4.0 removed the Babel pipeline (native Rust/Oxc CLI; v3.3.0 is the Babel terminus) — firsthand.

Canonical reading

Editorial annotations on why each piece matters — the articles themselves are the originals; read them there.

What browser translators do to a live DOMtranslate-shield research (measured 2026-09-02)

Measured account of the production crash class where a user turns on the browser's page translator and a React app breaks — the failure that i18n libraries do not cover because it happens in the browser, not in your message catalogs. Chrome's built-in translator, the Google bundle on other Chromium/Gecko/WebKit browsers and Yandex do NOT edit your text node: they build a new one wrapped in a <font> (Yandex: <ya-tr-span>) and detach yours, so React's reference points at an orphan — removeChild/insertBefore throw NotFoundError (two uncaught throws unmounted a whole root in their test app) and text writes land where nobody reads them, so a counter increments in memory while the screen keeps the old number. Edge's and Firefox's translators rewrite the live node in place and do not break this way. The widely pasted crash guard (no-op removeChild/insertBefore when the node is not a child; the thread is facebook/react#11538, opened 2018 and closed won't-fix) removes the errors but leaves the UI frozen on stale values. The cheap opt-out held on every engine tested: translate="no" on the element; class="notranslate" did NOT (Yandex translated it). Restoring the original node and letting the translator retranslate shows 100-150 ms of source-language text per update, versus 0 ms when the new value is mirrored into the translator's own wrapper. Caveats the author states: the flicker figure is from one probe page, one Chrome version and four updates; Safari is unmeasured; the author ships the competing translate-shield library (0.1.1, 3 stars), so treat the library as unproven even though the measurements are reproducible (Playwright commands in the repo).

Ahead-of-time compilation for next-intlJan Amann (next-intl author)

Why and how ICU messages are precompiled at build time into minified ASTs evaluated by a ~650-byte runtime, weighing function-based vs AST strategies against bundle size. Teaches the compile-time-vs-runtime and bundle-size tradeoffs from first principles.

ICU Message Syntax (core concepts)FormatJS (react-intl)

The canonical, example-driven explanation of the ICU message format — interpolation, plurals/selectordinal, select, number/date skeletons, rich text. ICU is a stable Unicode standard, so this foundational reference doesn't go stale.

Sources

The full explanation

The reviewed long-form essay behind this entry — the why, not a how-to. Also on GitHub.

About internationalization (i18n)

Diataxis: Explanation. This page builds understanding of the i18n decision — why the pick is framework-first, and why the deeper axis is compile-time vs runtime. It is not a setup guide and not a message-syntax reference (that is ICU's own documentation). The candidate list and one-line tradeoffs live in the index entry RB-E-I18N; there is no defer skill — this entry owns its own depth. Read this for the why.

The one distinction that organises everything: the format vs the machinery

Every "which i18n library?" debate conflates two layers that age at completely different speeds. The first is the message format — the syntax translators and developers share. For the serious React options that format is ICU: interpolation, plurals and selectordinal, select, number/date skeletons, rich text. ICU is a stable Unicode standard; the canonical FormatJS reference for it does not go stale, and the same syntax underlies react-intl, next-intl, and Lingui. Learning it is the durable investment.

The second layer is the machinery that evaluates the format — and this is where libraries actually differ. A runtime library ships a message parser to every user's device and interprets ICU strings on the fly. An ahead-of-time (AOT) library moves that work to the build: next-intl v4.8 precompiles ICU messages at build time into minified ASTs evaluated by a ~650-byte runtime — the parser never ships. The precompilation write-up by next-intl's author walks the design space honestly, weighing function-based vs AST compilation strategies against bundle size before landing on ASTs.

Hence the organising slogan: compile the locale, don't ship the library. The format is a standard you adopt; whether its evaluator rides in your bundle or runs in your build pipeline is the real choice — and it is a bundle-size choice, not a features choice.

The default, and why

Next.js app → next-intl. Outside Next.js (web or React Native) → i18next (web + RN, huge ecosystem); Lingui instead when compile-time extraction and bundle size outweigh plugin reach.

The first question is framework, not fashion: next-intl is Next.js-first, and in a Next.js app it brings the AOT story (v4.8's build-time ICU compilation) with framework-shaped integration. Outside Next.js the default forks on what you optimise for:

  • Reach and ecosystem → i18next (with react-i18next). Very popular, a big plugin ecosystem, and it works on web and React Native — the parity pick when one message layer must serve both renderers.
  • Bundle and boilerplate → Lingui (6.x). Compile-time message extraction, low-boilerplate authoring, i18n directives.

The index entry's when-clauses restate the fork: bundle-size-sensitive → next-intl in Next.js, Lingui elsewhere; maximum plugin ecosystem or RN + web parity → i18next; already on react-intl → FormatJS / react-intl; inline, key-free translations on React 19 with Vite or Next.js 16+ → fbtee.

The landscape, and when each one wins

next-intl (4.x) — Next.js-first. The headline is v4.8's ahead-of-time compilation: ICU messages become minified ASTs at build time, evaluated by a runtime of roughly 650 bytes, trading runtime message parsing for smaller bundles. It wins exactly where it says: Next.js apps, especially bundle-size-sensitive ones.

Lingui (6.x) — the compile-time pick outside Next.js. Message extraction happens at build time, authoring is low-boilerplate via i18n directives. It shares the AOT side of the fork with next-intl in the entry's bundle-size when-clause.

i18next / react-i18next — the runtime incumbent. Very popular, the biggest plugin ecosystem in the category, and one layer that spans web and React Native. It wins when reach, plugins, and web+RN parity outweigh the compile-time bundle story.

FormatJS / react-intl — the mature, framework-agnostic home of the ICU message format itself. Its documentation is the canonical ICU reference regardless of which library you ship.

fbtee — the modern continuation of Meta's deprecated fbt: inline translations with no keys and no t() wrappers, extracted by a compiler. 4.0 (2026-08-29) removed the Babel compiler — a Babel-dependent workflow stays on 3.3.0 — and 5.0.0 (2026-09-16) folded the CLI and Oxc transform into one devDependency, @nkzw/fbtee-compiler, with v5 Vite (>=6.3) and Next.js (>=16) plugins. It peers React ^19. It wins when inline, key-free authoring on a Vite or Next.js 16+ build is the goal.

Tradeoffs and failure modes to name out loud

  • Hardcoded strings in a production app. The index entry's own doctor signal: no i18n layer found anywhere in source in a production-stage repo. Its hint is the honest economics — if localization is ever on the roadmap, retrofitting hardcoded strings is the expensive path; wire the message layer early (i18next for web+RN reach, Lingui/next-intl for AOT-compiled bundles) even while you ship one locale.
  • Shipping the parser when you didn't have to. If the app is bundle-size-sensitive and the messages are ICU anyway, a runtime parser on every device is pure overhead the AOT options (next-intl v4.8, Lingui) were built to remove.
  • Treating the format as lock-in. The message syntax is ICU — a stable Unicode standard shared across react-intl, next-intl, and Lingui — so the syntax investment survives a library change; the machinery is what you'd swap.
  • Browser page translators vs a live React tree (web). Chrome's translator, the Google bundle and Yandex detach React's text nodes and insert their own, so updates freeze on stale text or removeChild/insertBefore throw; the widely pasted crash guard hides the errors but not the freeze. translate="no" on the element held on every engine tested; class="notranslate" did not. No i18n library covers this — it happens in the browser, not in your catalogs.
  • next-intl outside its home. It is Next.js-first by the entry's own tradeoff line; the non-Next.js defaults are i18next and Lingui.
  • Assuming all "compile-time" is the same compile. The entry groups next-intl and Lingui as AOT-compiled messages, but the mechanism it details (minified ASTs + ~650-byte runtime, function-based vs AST strategy analysis) is documented for next-intl specifically — verify Lingui's output shape against your own bundle budget rather than transferring the numbers.

How it interacts with the rest of the stack

  • Meta-frameworks (RB-E-META-FRAMEWORKS). The default's first branch is literally "is this a Next.js app" — the i18n pick is downstream of the framework pick.
  • Cross-platform (RB-E-CROSSPLATFORM). i18next's web + React Native reach is the entry's named parity option; if one shared logic package serves both platforms, that is the axis that decides.
  • Build & bundle (RB-E-BUILD). AOT message compilation is a build-pipeline concern, and its entire payoff is bundle size — the compile-time-vs-runtime fork in this entry is a bundle-budget decision wearing an i18n hat.

In one paragraph

Separate the format from the machinery. The format is ICU — interpolation, plurals, select, number/date skeletons — a stable Unicode standard shared by react-intl, next-intl, and Lingui, and the part worth learning once. The machinery either ships (runtime parsing on device) or compiles away (next-intl v4.8: ICU messages precompiled at build time into minified ASTs evaluated by a ~650-byte runtime) — compile the locale, don't ship the library. The default is framework-first: Next.js → next-intl; otherwise i18next (web + RN, huge plugin ecosystem) or Lingui (6.x, compile-time extraction, low boilerplate), with bundle-sensitivity pushing toward the AOT pair and ecosystem/parity pushing toward i18next. And wire the layer early: in a production repo with no i18n layer, retrofitting hardcoded strings is the expensive path.


See also: RB-E-META-FRAMEWORKS (the Next.js branch that picks next-intl), RB-E-CROSSPLATFORM (i18next as the web+RN parity layer), RB-E-BUILD (AOT compilation as a bundle-budget decision). Background reading: Jan Amann's precompilation write-up (the compile-time-vs-runtime tradeoff from first principles) and the FormatJS ICU syntax reference (the format itself, which doesn't go stale).

Related in ui: styling · component-libs · animation · lists · a11y · charts · editors · svg · maps · calendars · sheets · polish