--- createdAt: 2026-09-13 updatedAt: 2026-09-13 title: "next-intl vs @intlayer/next-intl: Same API, Different Bundle" description: What changes when the next-intl imports of a Next.js app are served by the @intlayer/next-intl compat adapter. Bundle size, leakage, component size and hydration measured on the same code, plus what the adapter keeps, ignores and cannot replace. keywords: - next-intl - use-intl - "@intlayer/next-intl" - Intlayer - Compat adapter - Migration - Internationalization - i18n - Benchmark - Bundle size - Blog - Next.js - TanStack Start - React slugs: - blog - next-intl-vs-intlayer-next-intl author: aymericzip --- # 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](https://github.com/intlayer-org/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](https://intlayer.org/blog/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, swapping `next-intl` for `@intlayer/next-intl` took 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, the `use-intl` equivalent (`@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** for `next-intl` and **5.5 KB** for native `next-intlayer`. Navigation and middleware are re-implemented on Intlayer's routing config; localized `pathnames` are 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: 1. **Import aliasing.** `createNextIntlPlugin()` from `@intlayer/next-intl/plugin` wraps `withIntlayer` and adds Webpack / Turbopack aliases so that `next-intl`, `next-intl/server`, `next-intl/navigation` and `next-intl/middleware` resolve to `@intlayer/next-intl`. No import in your codebase is renamed. 2. **JSON as source of truth.** The `syncJSON` plugin reads your existing `messages/{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. 3. **Call-site binding.** The Intlayer optimize pass (Babel or SWC) rewrites `useTranslations("about")` into a call that receives the `about` dictionary directly. The component no longer reaches a global message tree; it reaches its own content. ```tsx fileName="app/[locale]/about/page.tsx" // Your code, unchanged import { useTranslations } from "next-intl"; const AboutPage = () => { const t = useTranslations("about"); return

{t("title")}

; }; ``` ```tsx fileName="What the compiler emits (simplified)" import _dicHash_about from "../.intlayer/dictionaries/about.mjs"; import { useDictionary as useTranslations } from "@intlayer/next-intl"; const AboutPage = () => { const t = useTranslations(_dicHash_about); return

{t("title")}

; }; ``` 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 | `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](https://github.com/intlayer-org/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** with `next-intl` / `use-intl` 4.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 | 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-intl` configuration 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-intl` setup ships ~90% of foreign-page strings on every page. Reaching 0% with `next-intl` means the `scoped-*` setups: one namespace per route, and `pick(messages, [...])` in each page. The adapter reaches 0% from the naive code because the optimize pass binds each `useTranslations("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. In `next-intl`'s `scoped-static` setup 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-intlayer` sits at **141.3 KB**, +0.3 KB over the base app, with a 5.5 KB runtime. The adapter carries the `next-intl` API 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`). | 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-intl` in `dynamic` mode (129.7 KB) is within 1 KB of `use-intl`'s `scoped-dynamic` (128.7 KB), and 10 KB _above_ `use-intl`'s plain `dynamic` (119.4 KB). That plain `dynamic` row 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-intl` components average **76-87 KB** in every strategy, because `useTranslations` is bound to the provider's whole message object. The adapter averages **9-11 KB**. - **Locale switching is faster.** The optimized `use-intl` setups take **13-21 ms** to update `html[lang]`; the adapter takes **4-9 ms**. Fewer components re-render, and nothing is re-picked from a message tree. - **`static` keeps every locale.** The adapter's `static` row shows 49.7% locale leakage, the same as native Intlayer in `static` mode: 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. ```bash . ├── messages │ ├── en.json # every namespace, every page │ └── fr.json └── src ├── i18n.ts # getRequestConfig({ messages: await import(...) }) ├── middleware.ts # createMiddleware(routing) └── app/[locale] ├── layout.tsx # └── about/page.tsx # useTranslations("about") ``` **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. ```bash . ├── intlayer.config.ts # syncJSON({ source: ({ locale }) => `./messages/${locale}.json` }) ├── messages │ ├── en.json # unchanged, still the source of truth │ └── fr.json ├── .intlayer/ # generated: one dictionary per namespace, per locale └── src ├── middleware.ts # createMiddleware() now returns Intlayer's proxy └── app/[locale] ├── layout.tsx # (no messages prop) └── about/page.tsx # useTranslations("about") ← unchanged ``` `src/i18n.ts` and the `messages` prop go away. Everything else is identical. ## Migration in three steps ```bash packageManager="npm" npx intlayer init --interactive ``` ```bash packageManager="pnpm" pnpm dlx intlayer init --interactive ``` ```bash packageManager="yarn" yarn dlx intlayer init --interactive ``` ```bash packageManager="bun" bunx intlayer init --interactive ``` The command detects `next-intl` and installs `intlayer`, `next-intlayer`, `@intlayer/next-intl` and `@intlayer/sync-json-plugin`. Keep `next-intl` installed: it is a peer dependency of the adapter and provides the types. ```ts fileName="intlayer.config.ts" import { Locales, type IntlayerConfig } from "intlayer"; import { syncJSON } from "@intlayer/sync-json-plugin"; const config: IntlayerConfig = { internationalization: { locales: [Locales.ENGLISH, Locales.FRENCH, Locales.SPANISH], defaultLocale: Locales.ENGLISH, }, dictionary: { // "static" bundles every locale; "dynamic" loads the active one on demand importMode: "dynamic", }, plugins: [ syncJSON({ // ICU placeholders: {name}, {count, plural, one {# item} other {# items}} format: "icu", source: ({ locale }) => `./messages/${locale}.json`, location: "messages", }), ], }; export default config; ``` `messages/{locale}.json` stays where it is. Each top-level key becomes a dictionary; `useTranslations("about")` maps to the `about` dictionary. ```ts fileName="next.config.ts" import type { NextConfig } from "next"; import { createNextIntlPlugin } from "@intlayer/next-intl/plugin"; const withIntlayer = createNextIntlPlugin(); const nextConfig: NextConfig = {}; export default withIntlayer(nextConfig); ``` `createNextIntlPlugin()` composes `withIntlayer` (content watching, dictionary compilation, the optimize pass) and the `next-intl` → `@intlayer/next-intl` aliases for Webpack and Turbopack. Build, and the numbers in the tables above are yours. ### What you can delete afterwards | 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 compiled `about` dictionary. `t("does.not.exist")` is a TypeScript error, not a runtime fallback. - **`npx intlayer test`** fails CI when a locale is missing a key. **`npx intlayer fill`** translates the missing ones with the provider of your choice (OpenAI, Anthropic, Mistral, Gemini...) using your own key, and writes the result back into `messages/{locale}.json`. - **Visual Editor and CMS** work on the same dictionaries, so non-developers can edit `messages/fr.json` through a UI and the file updates. - **Incremental move to `.content.ts`.** Any component can switch from `useTranslations("about")` to `useIntlayer("about")` with a co-located content file, one at a time. JSON and `.content.ts` dictionaries coexist and merge. ## Limits to know before you start - **Routing config moves to `intlayer.config.ts`.** `createNavigation(routing)` and `createMiddleware(routing)` keep their signature but ignore the argument: locales, default locale and prefix strategy come from Intlayer's `routing` config. If you use `next-intl`'s localized `pathnames` (`/about` → `/a-propos`), the adapter does not interpolate them; Intlayer's `routing.rewrite` covers 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 the `next-intl` API surface. If you reach the point where every component has been moved to `useIntlayer`, drop the adapter. - **`messages`, `timeZone`, `now` on the provider are ignored.** The formatters are backed by native `Intl` and only the locale influences their output; if you rely on a forced time zone or a fixed `now` for hydration-stable dates, handle it at the call site. ## When to use which? - **Stay on `next-intl`** if your app is small, your bundle is not a concern, and your team is comfortable owning namespaces and `pick()` per page. - **Use `@intlayer/next-intl`** if you are on `next-intl` today 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 existing `next-intl` codebase. - **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.ts` files and the full feature set. ## Related comparisons - [next-intl vs Intlayer](https://intlayer.org/blog/next-intl-vs-intlayer) (the libraries, same benchmark) - [i18next vs @intlayer/i18next](https://intlayer.org/blog/i18next-vs-intlayer-i18next) (same adapter series) - [Lingui vs @intlayer/lingui](https://intlayer.org/blog/lingui-vs-intlayer-lingui) (same adapter series) - [vue-i18n vs @intlayer/vue-i18n](https://intlayer.org/blog/vue-i18n-vs-intlayer-vue-i18n) (same adapter series) - [Migration guide: next-intl to Intlayer](https://intlayer.org/doc/migration/next-intl) - [Compat adapter reference: next-intl](https://intlayer.org/doc/compatibility/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](https://github.com/intlayer-org/benchmark-bloom). Run it yourself. Refer to the ['Why Intlayer?' doc](https://intlayer.org/doc/why) for more details.