Ask your question and get a summary of the document by referencing this page and the AI provider of your choice
If you have an idea for improving this documentation, please feel free to contribute by submitting a pull request on GitHub.
GitHub link to the documentationCopy doc Markdown to clipboard
Lingui VS Intlayer | React & Next.js Internationalization (i18n) Benchmark
Lingui and Intlayer are the two libraries in this benchmark that rely on a compiler rather than a pure runtime. Lingui extracts messages from macros at build time and compiles catalogs per locale. Intlayer compiles per-component dictionaries and tree-shakes them per locale. On paper they should be close. The numbers show where they diverge.
The data comes from Benchmark Bloom, an open-source suite that builds the same application with each library and records what the browser actually downloads and executes.
tl;dr: Lingui is the closest to Intlayer on raw per-page JavaScript: 115-120 KB vs 118.6 KB on TanStack Start once lazy loading is configured, 148.6 KB vs 141.3 KB on Next.js. The gap opens elsewhere: a Lingui component compiled in isolation weighs 58-153 KB against 6-8 KB for Intlayer, hydration takes 28-34 ms against 11-14 ms, the source-locale fallback leaks 3-15% ofenstrings intofrpages in every optimized setup, and reaching that optimized setup means extracting, compiling and hand-picking catalogs per route. Intlayer gets there with no configuration.
In short
- Lingui - Macro-based (
t`...`,<Trans>,msg), ICU MessageFormat,.po/ JSON catalogs,lingui extract+lingui compileworkflow. Compiles message IDs to short hashes, supports dynamic catalog loading per locale. Well-established, framework-agnostic, strong translator tooling story around.po. - Intlayer - Component-centric content model.
.content.tsdictionaries sit next to the component they serve, a build-time compiler tree-shakes and lazy-loads them per component and per locale, strict TypeScript types are generated from your content, and missing translations fail at build time. Ships middleware, SEO helpers, a Visual Editor / CMS and AI-assisted translation.
Open the table in a modal to view all data content clearly
Badges update automatically. Snapshots will vary over time.
Side-by-side feature comparison
Open the table in a modal to view all data content clearly
| Feature | Intlayer (react-intlayer / next-intlayer) | Lingui (@lingui/core / @lingui/react) |
|---|---|---|
| Translations near components | ✅ Yes, .content.ts collocated with each component | ⚠️ Source strings inline in JSX via macros; translations in centralized .po catalogs |
| TypeScript integration | ✅ Strict types auto-generated from content | ⚠️ Macros are typed; message IDs are not, missing catalog entries are not surfaced |
| Missing translation detection | ✅ TypeScript error + build-time error/warning | ⚠️ lingui extract reports stats; runtime falls back to the source string |
| Rich content (JSX / Markdown / components) | ✅ Direct support | ✅ <Trans> with nested components |
| ICU support | ⚠️ WIP | ✅ Yes (plural, select, selectOrdinal macros) |
| Formatting (dates, numbers, currencies) | ✅ useNumber, useDate, ... (Intl under the hood) | ✅ i18n.date(), i18n.number() |
| Localized routing & middleware | ✅ Built-in proxy/middleware, getMultilingualUrls | ❌ Not core |
| SEO helpers (hreflang, sitemap, robots) | ✅ Built-in helpers | ❌ Manual |
| Synchronous server components | ✅ useIntlayer from next-intlayer/server works in any child server component | ⚠️ Needs an I18n instance per request, passed down or set via setI18n |
| Tree-shaking (ship only used content) | ✅ Per component, per locale, automated by the compiler | ⚠️ Per locale via lingui compile; per route needs manual catalog splitting |
| Lazy loading | ✅ importMode: 'dynamic' (one line of config) | ⚠️ Manual import() of compiled catalogs + i18n.load() / i18n.activate() |
| Purge unused content | ✅ Dead dictionaries are dropped at build time | ✅ lingui extract --clean removes obsolete messages |
| Testing missing translations (CLI / CI) | ✅ npx intlayer content test | ⚠️ lingui extract stats (no failing exit code by default) |
| Build pipeline | ✅ One plugin (@intlayer/swc / @intlayer/babel / vite-intlayer) | ⚠️ Macro plugin (Babel or SWC) + extract + compile steps |
| AI-powered translation | ✅ Built-in, uses your own provider keys | ❌ No |
| Visual Editor / CMS | ✅ Free Visual Editor + optional CMS | ❌ No (.po works with external TMS) |
| MCP server & Agent Skills | ✅ Yes | ❌ No |
| Ecosystem / community | ⚠️ Smaller but growing fast | ✅ Established, framework-agnostic |
The benchmark
What was measured
The Benchmark Bloom suite builds the same application with each library: 10 pages (home, about, blog, careers, contact, FAQ, pricing, products, settings, team), 10 locales (en, fr, es, de, it, pt, zh, ja, ko, ru), identical components and identical content. Pages are measured in en and fr. Each library is implemented in up to four loading strategies, from the naive setup to the optimal one:
Open the table in a modal to view all data content clearly
| Strategy | Description | Who does this |
|---|---|---|
| static | Every locale's compiled catalog imported and loaded upfront | Quick prototypes, AI-generated code |
| dynamic | Only the active locale's catalog is import()ed, but it holds all pages | Most projects |
| scoped-static | One catalog per route, all bundled upfront | Rare |
| scoped-dynamic | One catalog per route + lazy import(). Only the current page, current locale | Apps with a strict performance budget |
Intlayer has no "scoped" variant: the compiler scopes content per component automatically, so its static and dynamic rows are already scoped.
For each build, the suite records:
- Lib size: gzip size of an empty component that only imports the i18n library. The fixed cost of the runtime.
- Page JS: gzip JavaScript downloaded per page, averaged over all pages and locales.
- Locale leak %: share of translated strings found in the downloaded JS that belong to a locale the user is not viewing (fingerprinted on
enandfr, so 50% means "the other measured locale is fully present"; with 10 locales bundled, the real waste is higher). - Page leak %: share of translated strings found in the downloaded JS that belong to a page the user is not on.
- Component avg: average gzip size of each component compiled in isolation. Shows how much i18n runtime and catalog a single component drags in.
- E2E reactivity: wall-clock time between selecting a new locale and
html[lang]updating in the DOM (Playwright, 5 iterations). - Hydration: React hydration phase duration.
Numbers below come from the run dated 2026-09-12 with@lingui/react6.6.0 andintlayer9.5.1. The test application is deliberately small (a few dozen strings per locale), so leakage percentages describe a pattern: they grow with your content while the runtime cost stays fixed.
Results on Next.js
Open the table in a modal to view all data content clearly
| Library | Strategy | Lib size (gz) | Page JS avg (gz) | Locale leak | Page leak | Component avg (gz) | E2E reactivity | Hydration |
|---|---|---|---|---|---|---|---|---|
| base (no i18n) | - | 0.0 KB | 141.0 KB | 0.0% | 0.0% | 0.9 KB | 13.4 ms | 11.8 ms |
| Lingui | static | 11.9 KB | 207.4 KB | 50.0% | 90.0% | 73.3 KB | 15.3 ms | 15.2 ms |
| Lingui | dynamic | 11.9 KB | 145.4 KB | 2.8% | 89.9% | 19.9 KB | 15.7 ms | 12.7 ms |
| Lingui | scoped-static | 11.9 KB | 148.2 KB | 2.7% | 89.1% | 20.4 KB | 15.1 ms | 13.1 ms |
| Lingui | scoped-dynamic | 11.9 KB | 148.6 KB | 14.8% | 0.0% | 152.6 KB | 16.1 ms | 14.8 ms |
next-intlayer | static | 5.5 KB | 141.3 KB | 0.0% | 0.0% | 8.5 KB | 15.5 ms | 16.9 ms |
next-intlayer | dynamic | 5.5 KB | 141.3 KB | 0.0% | 0.0% | 6.9 KB | 15.3 ms | 15.9 ms |
How to read it
- Runtime cost. An empty component costs 11.9 KB gzip with Lingui, 5.5 KB with Intlayer. On the full page, Lingui's best configuration sits at +7.3 KB over Intlayer (148.6 vs 141.3 KB); Intlayer sits at +0.3 KB over the base app.
- The naive setup is expensive. Loading every compiled catalog upfront gives 207.4 KB per page, +66 KB over the base app. Half of the fingerprinted strings belong to the wrong locale, 90% to the wrong page.
- Dynamic loading fixes the locale, not the page. With one catalog per locale, page leakage stays at ~90%: the whole
frcatalog ships on every French page. Reaching 0% page leakage takes thescoped-dynamicsetup: one catalog per route, extracted and compiled separately, hand-picked in each page. - The source-locale fallback leaks. Even in the optimized setups, 3-15% of
enstrings ship insidefrpages. Lingui macros keep the source message available as a fallback, so it lands in the bundle next to the translation. Intlayer resolves fallbacks at build time and ships only the active locale. - Component size explodes in
scoped-dynamic. Each component compiled in isolation averages 152.6 KB, because every route's catalog is reachable from the component that imports it. The same component withuseIntlayer()averages 6.9 KB.
Results on TanStack Start
Open the table in a modal to view all data content clearly
| Library | Strategy | Lib size (gz) | Page JS avg (gz) | Locale leak | Page leak | Component avg (gz) | E2E reactivity | Hydration |
|---|---|---|---|---|---|---|---|---|
| base (no i18n) | - | 0.0 KB | 111.0 KB | 0.0% | 0.0% | 0.7 KB | 8.1 ms | 21.6 ms |
| Lingui | static | 11.2 KB | 152.2 KB | 50.0% | 90.0% | 58.0 KB | 3.9 ms | 19.9 ms |
| Lingui | dynamic | 11.2 KB | 115.2 KB | 9.3% | 0.0% | 85.5 KB | 5.9 ms | 28.0 ms |
| Lingui | scoped-static | 11.2 KB | 120.8 KB | 4.0% | 0.0% | 147.9 KB | 7.1 ms | 33.9 ms |
| Lingui | scoped-dynamic | 11.2 KB | 120.2 KB | 8.6% | 0.0% | 83.7 KB | 42.1 ms | 32.9 ms |
intlayer | static | 5.0 KB | 125.8 KB | 50.0% | 0.0% | 8.1 KB | 3.2 ms | 11.5 ms |
intlayer | dynamic | 5.0 KB | 118.6 KB | 0.0% | 0.0% | 6.3 KB | 3.6 ms | 14.1 ms |
@intlayer/lingui (compat) | dynamic | 10.3 KB | 137.0 KB | 9.9% | 0.0% | 12.8 KB | 2.9 ms | 19.7 ms |
How to read it
- On per-page JavaScript, Lingui wins by a hair.
dynamicLingui lands at 115.2 KB, 3.4 KB under Intlayer's 118.6 KB. Lingui's compiled catalogs with hashed IDs are compact, and the TanStack Start router splits routes well enough that page leakage is already 0% in thedynamicrow. - Everything around the page size goes the other way. Hydration takes 28-34 ms with Lingui versus 11-14 ms with Intlayer:
i18n.load()+i18n.activate()run on the client before React can hydrate. Components compiled in isolation weigh 58-148 KB versus 6-8 KB. Locale leakage never reaches 0% (4-9%) because of the source-locale fallback. - Locale switching in the optimized setup is slow.
scoped-dynamicLingui takes 42 ms to updatehtml[lang]: the new route catalog has to be fetched, loaded and activated before the switch is visible. Intlayer switches in 3-4 ms in both modes. - Intlayer's
staticrow already has 0% page leakage because only the dictionaries imported by the page's components are bundled. One line of config (importMode: 'dynamic') removes the locale leakage too. @intlayer/linguikeeps Lingui's macro syntax and serves it from Intlayer dictionaries. It trades some page size (137 KB, since the macro runtime stays) for smaller components (12.8 KB) and faster hydration than native Lingui. It's a migration step, not the destination.
Why the gap? Two compilers, two units of work
Both libraries compile. The difference is what they compile.
Lingui compiles catalogs. Macros in your source are extracted into a .po file per locale, then compiled into a JS module per locale. The unit is the locale. Splitting further, by route or by component, means creating multiple catalogs, configuring lingui.config.ts to extract each one from a different set of files, and loading the right one in each route. The runtime I18n instance is global; every useLingui() call subscribes the component to it.
Copy the code to the clipboard
Intlayer compiles dictionaries. Each .content.ts file is a dictionary bound to a key; the compiler resolves which component imports which key and emits, per dictionary and per locale, exactly the JSON that component needs. The unit is the component. Route scoping is a consequence: a page only pulls the dictionaries of the components it renders.
Copy the code to the clipboard
That's why the scoped-dynamic pattern is a build output for Intlayer and a configuration project for Lingui.
To get thedynamicrow's numbers, setdictionary.importMode: 'dynamic'inintlayer.config.ts. See the bundle optimization doc.
Developer experience
Setup
Lingui
Copy the code to the clipboard
Copy the code to the clipboard
Then add @lingui/babel-plugin-lingui-macro (or @lingui/swc-plugin) to the bundler, run lingui extract after editing source, lingui compile before building, and wrap the tree in <I18nProvider i18n={i18n}>.
Intlayer
Copy the code to the clipboard
Add intlayer() to vite.config.ts (or withIntlayer() to next.config.ts) and wrap the tree in <IntlayerProvider>. No extract or compile step: dictionaries are built when the bundler runs.
Component
Lingui
Copy the code to the clipboard
The English text lives in the component; the French one lives in src/locales/fr/messages.po under a hashed ID, after lingui extract has run. Forgetting to run it, or to compile, silently falls back to English.
Intlayer
Copy the code to the clipboard
Copy the code to the clipboard
Both locales sit in one file next to the component. A missing fr value is a build error, a wrong key is a TypeScript error.
Outside of components
Metadata, loaders, server functions: anywhere without a React tree.
Lingui
Copy the code to the clipboard
A fresh I18n instance per call, the right catalog loaded by hand, and msg + i18n._() instead of t. As the benchmark notes put it, knowing when to use t, t` ` , i18n.t(), msg or <Trans> "is not intuitive".
Intlayer
Copy the code to the clipboard
Keep the Lingui macros, get Intlayer dictionaries
@intlayer/lingui is a drop-in adapter for @lingui/core and @lingui/react. Macros keep compiling as before; the runtime i18n._() they compile to is served from Intlayer dictionaries, with .po sync plugins keeping your existing catalogs as a source of truth. ICU plurals and selects render identically.
Copy the code to the clipboard
Keep @lingui/babel-plugin-lingui-macro / @lingui/swc-plugin in the build, running before the Intlayer compiler. See the Lingui compatibility doc.
When to choose which?
- Choose Lingui if you want ICU MessageFormat with typed macros, your translators work in
.powith an existing TMS pipeline, you prefer source strings inline in JSX, and your team is fine owning the extract / compile / catalog-splitting workflow. Its per-page JS is competitive once lazy loading is set up. - Choose Intlayer if you want component-scoped content, strict TypeScript, build-time missing-key errors, zero-effort tree-shaking and lazy loading, small components, fast hydration, instant locale switching, and built-in editorial tooling (Visual Editor, CMS, AI translation, MCP server). Especially relevant for large, modular codebases and design systems.
- Choose
@intlayer/linguiif you are on Lingui and want to move to Intlayer dictionaries incrementally without touching macros.
Related comparisons
- next-intl vs Intlayer (same benchmark)
- i18next vs Intlayer (same benchmark)
- vue-i18n vs Intlayer benchmark (same benchmark)
- Compiler vs declarative i18n
GitHub STARs
GitHub stars are a strong indicator of a project's popularity, community trust, and long-term relevance. While not a direct measure of technical quality, they reflect how many developers find the project useful, follow its progress, and are likely to adopt it.
Conclusion
Lingui is the strongest runtime-plus-compiler library in this benchmark. Its compiled, hashed catalogs give it per-page JavaScript within a few KB of Intlayer, and even slightly under it on TanStack Start. If per-page bytes were the only metric, this would be a tie.
They aren't. Lingui's compiler stops at the locale; everything below that (per-route catalogs, lazy loading, keeping the fallback out of the bundle) is configuration, and the benchmark shows the cost of that boundary: components 10-20x larger, hydration 2-3x slower, 3-15% locale leakage that never goes away, and a 42 ms locale switch in the optimized setup. Intlayer's compiler works at the component level, so those numbers are 6-8 KB, 11-14 ms, 0% and 3-4 ms with no configuration.
All the raw data, the test apps and the scripts are in the Benchmark Bloom repository. Run it yourself.
Refer to the 'Why Intlayer?' doc for more details.
Comments
No comments yet. Be the first to share your thoughts.
