FormatJS vs Lingui Extraction Pipeline
Choosing between FormatJS (react-intl) and Lingui for a React extraction pipeline comes down to one fork in the road — do you want explicit, human-authored message IDs or auto-generated content hashes — and that single decision cascades into your CLI workflow, your bundle size, and how much your translators ever see of your source. Both libraries speak ICU MessageFormat, both ship an extract/compile CLI, and both can produce identical runtime output; the difference is the developer-experience contract each one asks you to sign. This comparison weighs the two end to end and closes with a recommendation matrix keyed to team profile, so you can pick without re-litigating it in three months.
This is a decision-stage comparison, not an advocacy piece. Where one tool is genuinely better for a profile we say so; where the gap is taste, we say that too. The frame throughout is the extraction pipeline: how a string in a .tsx file becomes a catalog entry, gets translated, and is compiled back into a runtime-ready bundle. Both stacks assume React 18+, a TypeScript codebase, and an ICU-aware translation management system downstream.
Prerequisites
Concept & spec — what “extraction pipeline” means here
An extraction pipeline is the toolchain that scans source files for marked-up messages, emits a catalog of id → ICU string pairs for translators, and later compiles the translated catalogs into a runtime-loadable form. Both FormatJS and Lingui implement the same three stages — extract, translate, compile — and both render through ICU MessageFormat, the Unicode TR35 / CLDR grammar for plurals, selects, and ordinals, formalized for JavaScript runtimes by ECMA-402 (Intl). Because both consume CLDR plural categories, a message that is correct in one is correct in the other; nothing about pluralization forces the choice.
What differs is where the ID comes from and whether a macro rewrites your source at build time. This page sits within Framework i18n & Component Routing and is the React-specific tooling decision that precedes any of the component patterns in React i18next component patterns — note that react-i18next is a third option entirely, but FormatJS and Lingui are the two that center an extraction CLI rather than runtime key lookup.
The two ID philosophies
FormatJS (react-intl) defaults to explicit IDs: you author defineMessages or <FormattedMessage id="..."> and you own the key namespace. There is an auto-ID mode (idInterpolationPattern) that hashes [contenthash:sha512:base64:6] of the default message plus description, but the idiomatic posture is human-named keys like checkout.button.submit.
Lingui defaults to auto-generated IDs: its t and Trans macros hash the source message text so the ID is the English string’s fingerprint. You write t\Add to cart`and never name a key. You can opt into explicit IDs withmsg({ id: ‘cart.add’ })`, but the grain of the tool is hash-based.
// FormatJS (react-intl) — explicit, human-named IDs
import { FormattedMessage } from 'react-intl';
<FormattedMessage id="cart.add" defaultMessage="Add to cart" />;
// Lingui — macro, auto-hashed ID derived from the source text
import { Trans } from '@lingui/react/macro';
<Trans>Add to cart</Trans>; // id becomes a content hash unless you override it
Step-by-step: running each pipeline
The two pipelines map step-for-step. Following both back to back is the fastest way to feel the DX difference.
-
Mark a message in source. In FormatJS, wrap text in
<FormattedMessage>/intl.formatMessagewith an explicitidanddefaultMessage. In Lingui, wrap it in<Trans>ort`...`and let the macro own the ID. This single choice is the whole comparison in miniature. -
Configure the build transform. FormatJS needs
babel-plugin-formatjsso the CLI can statically find messages and so production builds can stripdefaultMessage/descriptionmetadata. Lingui needs its macro plugin (Babel or SWC) to rewritet\…`into a runtimei18n._()` call at build time.
// FormatJS: babel.config.json
{ "plugins": [["formatjs", { "idInterpolationPattern": "[sha512:contenthash:base64:6]", "removeDefaultMessage": true }]] }
// Lingui: .babelrc (or use the SWC plugin in next.config)
{ "plugins": ["@lingui/babel-plugin-lingui-macro"] }
- Extract to a catalog. Run
formatjs extractto emit a flat JSON map ofid → { defaultMessage, description }, orlingui extractto update per-locale PO or JSON catalogs with new and obsolete entries flagged.
# FormatJS
npx formatjs extract 'src/**/*.{ts,tsx}' --out-file lang/en.json \
--id-interpolation-pattern '[sha512:contenthash:base64:6]'
# Lingui
npx lingui extract
-
Hand catalogs to translators. Both formats round-trip through a translation management system. FormatJS JSON needs a small adapter for most TMSes; Lingui’s PO output drops straight into any gettext-aware tool. This is where PO / XLIFF tooling matters if your TMS is XLIFF-only.
-
Compile for runtime.
formatjs compileflattens each locale into anid → ICU stringmap yourIntlProviderloads;lingui compileturns each PO catalog into an optimized JS module of compiled message functions. Lingui’s compile step pre-parses ICU into executable form, which is the root of its smaller runtime.
npx formatjs compile lang/fr.json --out-file compiled/fr.json # FormatJS
npx lingui compile # Lingui
- Load at runtime. Wrap the app once in
IntlProvider(FormatJS) orI18nProvider(Lingui), passing the compiled messages for the active locale. From here both render identical ICU output.
Configuration reference
| Option | Type | Description / default |
|---|---|---|
idInterpolationPattern (FormatJS) |
string |
Hash template for auto-IDs. Default unset → explicit IDs required. Common: [sha512:contenthash:base64:6]. |
removeDefaultMessage (FormatJS) |
boolean |
Strip defaultMessage from production bundles to shrink size. Default false. |
ast (FormatJS compile) |
boolean |
Emit pre-parsed ICU AST instead of strings for faster runtime parsing. Default false. |
runtimeConfigModule (Lingui) |
string |
Override the @lingui/core import for custom setups. Default @lingui/core. |
compileNamespace (Lingui) |
'es'|'cjs'|'ts' |
Output module format from lingui compile. Default cjs. |
fallbackLocales (Lingui) |
object |
Per-locale fallback chain for missing keys. Default none. |
pseudoLocale (Lingui) |
string |
Locale code that triggers pseudolocalization for UI fitting. Default none. |
How they compare on the axes that matter
Message ID strategy
Explicit IDs (FormatJS-idiomatic) give you stable keys that survive copy edits — fixing a typo in the English source does not orphan every translation. The cost is a namespace you must govern and lint for collisions. Auto-hash IDs (Lingui-idiomatic) eliminate naming entirely and make duplicate strings deduplicate for free, but any change to the source text mints a new ID, so a comma fix can silently mark a string untranslated. Teams that edit copy constantly lean explicit; teams that value zero key bikeshedding lean hash.
Macro vs runtime
Lingui’s macros do real work at build time: t\Hello ${name}`becomes a compiled message reference, so the ICU template never ships as a parseable string and the parser is largely tree-shaken away. FormatJS is more runtime-centric —babel-plugin-formatjsextracts metadata but theintl.formatMessagecall parses ICU at runtime unless you ship the AST format. Macros mean a slightly more opaque build (at``` that does nothing without the plugin) in exchange for the smallest runtime; the runtime model is more transparent and debuggable at the cost of bytes.
ICU support
Parity. Both implement full ICU MessageFormat — plural, selectordinal, select, nested arguments, and rich-text tags. Anything you can express in complex ICU plural syntax works in either. The only nuance: FormatJS exposes rich-text via tag functions on <FormattedMessage>, while Lingui handles inline markup through <Trans> component slots. Different ergonomics, same expressive power.
Bundle size
Lingui generally ships less runtime because its compile step pre-parses ICU and tree-shakes the parser for messages without plurals. FormatJS’s @formatjs/intl core plus the ICU parser is heavier unless you compile to AST and strip default messages. For a string-heavy app the delta is real but rarely decisive — both are well under the weight of a typical component library. If you ship to bandwidth-constrained markets, Lingui’s compiled output is the safer default.
Developer experience
FormatJS feels like a library: explicit, verbose, every message visible as data. That verbosity is an asset for large teams that want grep-able IDs and strict review. Lingui feels like a language extension: terser source, less ceremony, but a magic macro you must trust. Onboarding a junior dev is faster with Lingui’s <Trans>; auditing a 2,000-key catalog is easier with FormatJS’s explicit map.
Framework variants
React / Vite. Both work cleanly. FormatJS uses babel-plugin-formatjs via @vitejs/plugin-react; Lingui ships a dedicated @lingui/vite-plugin that wires the macro and catalog loader.
Next.js (App Router). FormatJS integrates via the Babel transform and an IntlProvider in a client boundary; pair it with your Next.js i18n routing for locale-segment resolution. Lingui offers an SWC plugin so you keep Next’s default SWC compiler instead of falling back to Babel — a meaningful build-speed win on large apps.
Vue / Angular. Neither library is React-only in spirit, but in practice FormatJS’s @formatjs/intl core is framework-agnostic while react-intl is the React binding; Lingui has official React and vanilla bindings. For Vue or Angular you would more often reach for ecosystem-native tools, so this decision is sharpest inside React.
Node.js backend. For server-rendered emails or API messages, FormatJS’s @formatjs/intl createIntl runs headless cleanly. Lingui’s i18n core object is equally usable server-side; load the compiled catalog and call i18n._().
Verification
Wire both extraction commands into CI so an untranslated or orphaned string fails the build before merge.
# Fail CI if extraction would change committed catalogs (drift check)
npx formatjs extract 'src/**/*.tsx' --out-file lang/en.json && git diff --exit-code lang/
# Lingui: fail on missing translations
npx lingui extract && npx lingui compile --strict
Expected output on a clean tree: no diff and a zero exit code. A non-zero exit means a developer added a message without re-extracting, which is exactly the gate you want. Slot this into your i18n CI gates job.
Recommendation matrix by team profile
| Team profile | Pick | Why |
|---|---|---|
| Large org, strict review, governed key namespace | FormatJS | Explicit IDs are grep-able and survive copy edits; verbose-but-auditable. |
| Small/fast team, frequent UI iteration, minimal ceremony | Lingui | Auto-hash IDs and terse <Trans> remove naming overhead. |
| Bandwidth-sensitive (mobile-first, emerging markets) | Lingui | Compiled catalogs and tree-shaken parser yield smaller runtime. |
| Next.js App Router, build speed critical | Lingui | SWC plugin avoids dropping back to Babel. |
| Copy changes constantly, translations must not orphan | FormatJS | Stable explicit IDs decouple key from source text. |
| Gettext/PO-based TMS already in place | Lingui | Native PO output round-trips with no adapter. |
| You want the most transparent, debuggable runtime | FormatJS | No build-time macro; messages are plain data. |
If you are genuinely undecided, default to Lingui for greenfield React apps (less boilerplate, smaller bundle) and FormatJS for large existing react-intl codebases or strict-governance orgs (explicit IDs, easier audits). Neither is a wrong answer; both are production-grade and both will outlive the project that chose them.
Common pitfalls
- Switching ID strategy mid-project. Moving from explicit to hash IDs (or back) re-keys every catalog. Decide once; migrating later means re-importing all translations.
- Forgetting the build transform. A Lingui
t\`without its macro plugin ships untranslated; a FormatJS extract with nobabel-plugin-formatjs` silently misses messages. Verify the plugin loads in CI. - Skipping the compile step. Loading raw extracted catalogs at runtime forces full ICU parsing on the client and inflates bundle size — always ship compiled output.
- Source edits orphaning hash IDs. Under Lingui’s auto-IDs, a punctuation fix mints a new key. Use explicit IDs for high-churn strings or accept the re-translation. See missing-translation warnings for diagnosing the symptom.
- Assuming ICU differences. They are at parity; if a plural renders wrong it is a CLDR category issue, not a library one — debug the message, not the tool.
FAQ
Do FormatJS and Lingui produce different translated output?
No. Given the same ICU message and the same locale data, both render byte-identical output, because both delegate plural and number formatting to ECMA-402 Intl and CLDR. The choice is about the pipeline and DX — ID strategy, build model, bundle size — not about what the end user sees on screen.
Can I migrate from react-intl to Lingui without re-translating everything?
Partially. If you adopt Lingui’s explicit-ID mode and reuse your existing FormatJS keys, catalogs map across. If you let Lingui auto-hash, every ID changes and you must re-import translations keyed to the new hashes. Plan the ID strategy before migrating, not during.
Which one is smaller in production?
Lingui is usually smaller because lingui compile pre-parses ICU and tree-shakes the runtime parser for messages without plurals. FormatJS can close most of the gap by compiling to the AST format and enabling removeDefaultMessage, but Lingui’s compiled-function model wins by default for string-heavy apps.
Is the Lingui macro a problem for debugging?
It adds a layer of indirection — t\Hello`` only works after the macro rewrites it — so a misconfigured build yields confusing “function is not defined” errors. Once the plugin is correctly wired it is invisible. FormatJS avoids macros entirely, which some teams prefer for transparency at the cost of a heavier runtime.
Do I need ICU MessageFormat knowledge for either?
Yes, equally. Both expose the full ICU grammar, so plurals, selects, and ordinals are authored identically. Solid grounding in ICU MessageFormat pays off regardless of which extraction tool you adopt.
Related
- React i18next component patterns — the third React option, centered on runtime key lookup rather than an extraction CLI.
- Next.js i18n routing setup — locale-segment resolution to pair with either extraction stack.
- ICU Message Format Deep Dive — the shared grammar both tools compile and render.
- PO / XLIFF format bridging — adapting catalog output to an XLIFF-only translation system.
Part of Framework i18n & Component Routing.