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
next-intl VS @intlayer/next-intl | Same API, Different Bundle
@intlayer/next-intl is a compat adapter: it exposes the next-intl API (useTranslations, getTranslations, useLocale, t.rich(), ICU plurals, NextIntlClientProvider...) and serves it from dictionaries compiled by Intlayer. The application code does not change. The bundle does.
This article compares the two on the same Next.js application, built once with next-intl and once with the adapter. The numbers come from Benchmark Bloom, an open-source suite that records what the browser actually downloads. If you want the next-intl vs Intlayer comparison as libraries, read next-intl vs Intlayer. This one is about what the adapter changes when you keep your components as they are.
tl;dr: On the same Next.js app, swappingnext-intlfor@intlayer/next-intltook the per-page JavaScript from 153.6 KB to 147.5 KB gzip, the average component from 21.8 KB to 8.1 KB, foreign-page string leakage from ~90% to 0%, and hydration from 14.7 ms to 12.8 ms, with no component edited. On TanStack Start, theuse-intlequivalent (@intlayer/use-intl) cut components from 76-87 KB to 9-11 KB and locale switching from 7-21 ms to 4-9 ms. The adapter costs 8.0 KB of runtime versus 14.7 KB fornext-intland 5.5 KB for nativenext-intlayer. Navigation and middleware are re-implemented on Intlayer's routing config; localizedpathnamesare the one feature not carried over.
What @intlayer/next-intl is
next-intl is a runtime: getRequestConfig loads a messages/{locale}.json per request, NextIntlClientProvider ships it to the client, and useTranslations("about") reads keys from that object at render time. Every optimization (namespaces, pick(messages, [...]) per page, lazy loading) is yours to write.
@intlayer/next-intl keeps the first and last part of that chain and replaces the middle. Your components still call useTranslations("about"); what they receive comes from an Intlayer dictionary compiled at build time, scoped to that component, in the active locale only.
Three mechanisms make it work:
- Import aliasing.
createNextIntlPlugin()from@intlayer/next-intl/pluginwrapswithIntlayerand adds Webpack / Turbopack aliases so thatnext-intl,next-intl/server,next-intl/navigationandnext-intl/middlewareresolve to@intlayer/next-intl. No import in your codebase is renamed. - JSON as source of truth. The
syncJSONplugin reads your existingmessages/{locale}.json, splits its top-level keys into one dictionary per namespace, and writes translations back to the same files when the CLI or the CMS updates them. Your translators' workflow is untouched. - Call-site binding. The Intlayer optimize pass (Babel or SWC) rewrites
useTranslations("about")into a call that receives theaboutdictionary directly. The component no longer reaches a global message tree; it reaches its own content.
Copy the code to the clipboard
Copy the code to the clipboard
That rewrite is why the component-size and page-leakage columns below move: a page only pulls the dictionaries of the components it renders, and only in the locale being served.
What the adapter keeps, ignores, and does not replace
Open the table in a modal to view all data content clearly
next-intl API | With @intlayer/next-intl |
|---|---|
useTranslations("ns") / getTranslations("ns") | ✅ Kept. Bound to the ns dictionary at build time. Keys are typed against your content. |
getTranslations({ locale, namespace }) | ✅ Kept |
t("key", { name }), t.rich(), t.markup(), t.raw() | ✅ Kept. ICU plurals, select, selectordinal, #, {ts, date, long} run through Intlayer's ICU resolver |
useLocale() / getLocale() / setRequestLocale() / setLocale | ✅ Kept |
useFormatter() | ✅ Kept. dateTime, number, relativeTime, list, dateTimeRange bridge to native Intl |
NextIntlClientProvider | ✅ Kept. The messages, timeZone and now props are accepted but ignored (a dev warning tells you so) |
getMessages() | ✅ Kept for compatibility; no longer needed |
getRequestConfig() in src/i18n.ts | ⚠️ Not needed. Dictionaries are compiled at build time; there is no per-request message loading |
defineRouting() | ✅ Kept. Omitted fields (locales, defaultLocale, localePrefix) are read from intlayer.config.ts |
createNavigation(), Link, redirect, usePathname, useRouter | ✅ Kept. Re-implemented on Intlayer's routing config; the routing argument is accepted but ignored |
pathnames (localized route names) | ❌ Accepted for typing, not interpolated. Keep plain pathnames or move that mapping to Intlayer's rewrite |
createMiddleware() | ✅ Kept. Returns Intlayer's proxy; sets the NEXT_LOCALE cookie so useLocale() and your switcher keep working |
NEXT_LOCALE cookie | ✅ Read by default (unless you configure routing.storage yourself) |
Bare useTranslations() with no namespace | ⚠️ Works, but the call site is not bound: it resolves through the runtime registry. Pass a namespace to get the bundle gains |
The benchmark
What was measured
The Benchmark Bloom suite builds the same application with each setup: 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.
next-intl was built in four loading strategies, from the naive setup (messages/{locale}.json loaded whole) to the optimal one (one namespace per route + per-page pick()). The adapter was built on the same components as the naive setup, with only next.config.ts and intlayer.config.ts changed. It has no "scoped" variant: the compiler scopes content per component, 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.
- 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 withnext-intl/use-intl4.14.2 and@intlayer/*9.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
| Setup | 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 |
next-intl | static | 14.7 KB | 153.6 KB | 4.2% | 89.8% | 21.8 KB | 16.0 ms | 14.7 ms |
next-intl | dynamic | 14.7 KB | 153.6 KB | 9.7% | 89.9% | 21.8 KB | 15.6 ms | 14.8 ms |
next-intl | scoped-static | 14.7 KB | 153.6 KB | 0.0% | 0.0% | 80.1 KB | 17.9 ms | 17.4 ms |
next-intl | scoped-dynamic | 14.7 KB | 153.6 KB | 0.0% | 0.0% | 22.9 KB | 17.8 ms | 16.8 ms |
@intlayer/next-intl | static | 8.0 KB | 147.5 KB | 0.0% | 0.0% | 8.1 KB | 14.5 ms | 12.8 ms |
@intlayer/next-intl | dynamic | 8.0 KB | 148.7 KB | 0.0% | 0.0% | 8.1 KB | 11.7 ms | 12.8 ms |
next-intlayer (native) | static | 5.5 KB | 141.3 KB | 0.0% | 0.0% | 8.5 KB | 15.5 ms | 16.9 ms |
next-intlayer (native) | dynamic | 5.5 KB | 141.3 KB | 0.0% | 0.0% | 6.9 KB | 15.3 ms | 15.9 ms |
How to read it
- Same components, 6 KB less per page. The adapter build of the naive app lands at 147.5 KB, under every
next-intlconfiguration including the fully optimized one (153.6 KB). The runtime itself is the difference: 8.0 KB versus 14.7 KB, paid on every page. - Leakage goes to 0% without touching a component. The naive
next-intlsetup ships ~90% of foreign-page strings on every page. Reaching 0% withnext-intlmeans thescoped-*setups: one namespace per route, andpick(messages, [...])in each page. The adapter reaches 0% from the naive code because the optimize pass binds eachuseTranslations("ns")to its own dictionary. - Components shrink 2.7x. A component compiled in isolation averages 21.8 KB with
next-intl(it reaches the provider and the message tree) and 8.1 KB with the adapter. Innext-intl'sscoped-staticsetup that number goes up to 80 KB, because every route's namespace file becomes reachable from the page that picks it. - Hydration is 2 ms faster (12.8 vs 14.7 ms): there is no message object to deserialize from the RSC payload before React can hydrate.
- The adapter is not the native runtime.
next-intlayersits at 141.3 KB, +0.3 KB over the base app, with a 5.5 KB runtime. The adapter carries thenext-intlAPI surface (useFormatter,t.rich, the ICU resolver) on top of Intlayer's core, hence 8.0 KB and +6 KB per page. It is the bridge, not the destination.
Results on TanStack Start (use-intl)
use-intl is the framework-agnostic core of next-intl. Its adapter, @intlayer/use-intl, follows the same design with a Vite plugin (@intlayer/use-intl/plugin).
Open the table in a modal to view all data content clearly
| Setup | 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 |
use-intl | static | 14.1 KB | 179.8 KB | 50.0% | 89.8% | 76.0 KB | 6.7 ms | 15.3 ms |
use-intl | dynamic | 14.1 KB | 119.4 KB | 0.0% | 89.8% | 75.9 KB | 7.0 ms | 15.4 ms |
use-intl | scoped-static | 14.1 KB | 128.7 KB | 0.0% | 0.0% | 87.1 KB | 20.9 ms | 24.8 ms |
use-intl | scoped-dynamic | 14.1 KB | 128.7 KB | 0.0% | 0.0% | 87.1 KB | 13.3 ms | 25.9 ms |
@intlayer/use-intl | static | 7.3 KB | 135.8 KB | 49.7% | 0.0% | 10.9 KB | 4.2 ms | 10.5 ms |
@intlayer/use-intl | dynamic | 7.3 KB | 129.7 KB | 0.0% | 0.0% | 9.3 KB | 8.7 ms | 16.1 ms |
intlayer (native) | static | 5.0 KB | 125.8 KB | 50.0% | 0.0% | 8.1 KB | 3.2 ms | 11.5 ms |
intlayer (native) | dynamic | 5.0 KB | 118.6 KB | 0.0% | 0.0% | 6.3 KB | 3.6 ms | 14.1 ms |
How to read it
- Per-page bytes are a wash against the optimized
use-intl.@intlayer/use-intlindynamicmode (129.7 KB) is within 1 KB ofuse-intl'sscoped-dynamic(128.7 KB), and 10 KB aboveuse-intl's plaindynamic(119.4 KB). That plaindynamicrow still leaks 90% of foreign-page strings; the byte count is low because the test app's content is small. The adapter's 0% is what stays flat as content grows. - Components are 7-9x smaller.
use-intlcomponents average 76-87 KB in every strategy, becauseuseTranslationsis bound to the provider's whole message object. The adapter averages 9-11 KB. - Locale switching is faster. The optimized
use-intlsetups take 13-21 ms to updatehtml[lang]; the adapter takes 4-9 ms. Fewer components re-render, and nothing is re-picked from a message tree. statickeeps every locale. The adapter'sstaticrow shows 49.7% locale leakage, the same as native Intlayer instaticmode: all locales are bundled, only the page's dictionaries are. One line of config (importMode: 'dynamic') removes it.
Why the numbers move
Nothing in the component changed, so the gains come entirely from what useTranslations is bound to.
With next-intl, the binding is the provider. NextIntlClientProvider receives the whole messages object for the locale; every useTranslations("about") reads from it. The bundler sees one component importing one hook that reads one context, and cannot know that only the about branch is used. The routes below all share the same message object, so the page-leak column reads ~90% until you split the file yourself.
Copy the code to the clipboard
With @intlayer/next-intl, the binding is the dictionary. syncJSON turns messages/en.json into one dictionary per top-level key; the compiler resolves which component calls useTranslations("about") and hands it about directly, in the active locale, as an import the bundler can trace and split.
Copy the code to the clipboard
src/i18n.ts and the messages prop go away. Everything else is identical.
Migration in three steps
Install
bashCopy codeCopy the code to the clipboard
The command detects
next-intland installsintlayer,next-intlayer,@intlayer/next-intland@intlayer/sync-json-plugin. Keepnext-intlinstalled: it is a peer dependency of the adapter and provides the types.Point Intlayer at your messages
intlayer.config.tsCopy codeCopy the code to the clipboard
messages/{locale}.jsonstays where it is. Each top-level key becomes a dictionary;useTranslations("about")maps to theaboutdictionary.Wrap next.config.ts
next.config.tsCopy codeCopy the code to the clipboard
createNextIntlPlugin()composeswithIntlayer(content watching, dictionary compilation, the optimize pass) and thenext-intl→@intlayer/next-intlaliases for Webpack and Turbopack. Build, and the numbers in the tables above are yours.
What you can delete afterwards
Open the table in a modal to view all data content clearly
| File / pattern | Why |
|---|---|
getRequestConfig in src/i18n.ts | No per-request message loading. Keep the file only if it also exports createNavigation helpers |
messages={...} on NextIntlClientProvider | The adapter reads compiled output; the prop is ignored and logs a warning in development |
await getMessages() in layouts | Same reason |
Per-page pick(messages, [...]) | The compiler does the picking, per component |
What you gain beyond bytes
- Typed keys.
useTranslations("about")is typed against the compiledaboutdictionary.t("does.not.exist")is a TypeScript error, not a runtime fallback. npx intlayer testfails CI when a locale is missing a key.npx intlayer filltranslates the missing ones with the provider of your choice (OpenAI, Anthropic, Mistral, Gemini...) using your own key, and writes the result back intomessages/{locale}.json.- Visual Editor and CMS work on the same dictionaries, so non-developers can edit
messages/fr.jsonthrough a UI and the file updates. - Incremental move to
.content.ts. Any component can switch fromuseTranslations("about")touseIntlayer("about")with a co-located content file, one at a time. JSON and.content.tsdictionaries coexist and merge.
Limits to know before you start
- Routing config moves to
intlayer.config.ts.createNavigation(routing)andcreateMiddleware(routing)keep their signature but ignore the argument: locales, default locale and prefix strategy come from Intlayer'sroutingconfig. If you usenext-intl's localizedpathnames(/about→/a-propos), the adapter does not interpolate them; Intlayer'srouting.rewritecovers that case but it is a separate change. - Namespace-less
useTranslations()is not bound. The optimize pass needs a static namespace to know which dictionary to import. A bare call still works, through a runtime registry that references every dictionary, which is exactly the leakage you were trying to remove. Pass the namespace. - The adapter is not free. 8.0 KB of runtime versus 5.5 KB for
next-intlayer, and +6-7 KB per page over the native build. It pays for thenext-intlAPI surface. If you reach the point where every component has been moved touseIntlayer, drop the adapter. messages,timeZone,nowon the provider are ignored. The formatters are backed by nativeIntland only the locale influences their output; if you rely on a forced time zone or a fixednowfor hydration-stable dates, handle it at the call site.
When to use which?
- Stay on
next-intlif your app is small, your bundle is not a concern, and your team is comfortable owning namespaces andpick()per page. - Use
@intlayer/next-intlif you are onnext-intltoday and want the bundle, leakage and hydration gains, typed keys and the CLI / CMS tooling without a rewrite. This is the recommended entry point for any existingnext-intlcodebase. - Go native (
next-intlayer) for new projects, or once the adapter has done its job. It is the lightest of the three (5.5 KB, +0.3 KB per page) and unlocks synchronous server components, per-component.content.tsfiles and the full feature set.
Related comparisons
- next-intl vs Intlayer (the libraries, same benchmark)
- i18next vs @intlayer/i18next (same adapter series)
- Lingui vs @intlayer/lingui (same adapter series)
- vue-i18n vs @intlayer/vue-i18n (same adapter series)
- Migration guide: next-intl to Intlayer
- Compat adapter reference: next-intl
Conclusion
@intlayer/next-intl does one thing: it changes what useTranslations is bound to, from a provider holding every message to a dictionary compiled for that component. On the same Next.js app that is worth 6 KB per page, 2.7x smaller components, 0% leakage and 2 ms of hydration, before anyone opens a component file. Navigation and middleware keep their API on top of Intlayer's routing config, and the native next-intlayer runtime remains lighter still.
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.
