---
createdAt: 2026-09-13
updatedAt: 2026-09-13
title: "vue-i18n vs @intlayer/vue-i18n: Same API, Different Bundle"
description: What changes when a Vue 3 app keeps its vue-i18n calls but serves them through the @intlayer/vue-i18n compat adapter. Per-page JavaScript, runtime size, component size and leakage measured on the same Vite + Vue code, plus what the adapter keeps, ignores and cannot replace.
keywords:
- vue-i18n
- "@intlayer/vue-i18n"
- Intlayer
- Compat adapter
- Migration
- Internationalization
- i18n
- Benchmark
- Bundle size
- Blog
- Vue
- Nuxt
- Vite
slugs:
- blog
- vue-i18n-vs-intlayer-vue-i18n
author: aymericzip
---
# vue-i18n VS @intlayer/vue-i18n | Same API, Different Bundle
`@intlayer/vue-i18n` is a compat adapter: it exposes the `vue-i18n` API (`createI18n`, `useI18n`, `t()`, `d()`, `n()`, `$t`, `v-t`, `i18n.global.locale`...) and serves it from dictionaries compiled by Intlayer. Your `.vue` files do not change. What `t("footer.github")` is bound to does.
This article measures that swap on the same Vite + Vue 3 application, built once with `vue-i18n` and once with the adapter. The numbers come from [Benchmark Bloom](https://github.com/intlayer-org/benchmark-bloom). For `vue-i18n` and Intlayer compared as libraries, read [vue-i18n vs Intlayer](https://intlayer.org/blog/vue-i18n-vs-intlayer) and the [vue-i18n vs Intlayer benchmark](https://intlayer.org/blog/vue-i18n-vs-intlayer-benchmark). This one is about what the adapter changes when you keep your components as they are.
> **tl;dr**: On the same Vite + Vue 3 app, replacing `vue-i18n` with `@intlayer/vue-i18n` took the per-page JavaScript from **134.9 KB to 47.0 KB** gzip (the app without i18n weighs 41.3 KB), the runtime from **24.3 KB to 7.9 KB**, the average component from **196 KB to 8.4 KB**, and foreign-page string leakage from **90% to 0%**, with no `.vue` file edited. `createI18n({ messages })` keeps working as a fallback; remove the JSON imports to get the numbers above. SFC `` blocks and runtime `setLocaleMessage()` are the two features that do not carry over.
## What `@intlayer/vue-i18n` is
`vue-i18n` is a runtime. `createI18n({ messages: { en, fr, ... } })` builds a global instance holding every message of every locale; `useI18n()` binds each component to it; `t("footer.github")` walks the tree at render time. That design is what makes SFC `` blocks and `setLocaleMessage()` possible, and it is also why every component's dependency graph includes the whole tree.
`@intlayer/vue-i18n` keeps the API and replaces the tree:
1. **Import aliasing.** `vueI18nVitePlugin()` from `@intlayer/vue-i18n/plugin` wraps `vite-intlayer` and adds a `resolve.alias` so that `vue-i18n` resolves to `@intlayer/vue-i18n`. No import is renamed.
2. **JSON as source of truth.** The `syncJSON` plugin reads your existing `locales/{locale}.json` with `format: "vue-i18n"` (so `{name}`, `{0}` list interpolation and `"car | cars"` pipe plurals are parsed correctly) and writes translations back when the CLI or the CMS updates them.
3. **Call-site binding.** The Intlayer optimize pass rewrites `useI18n()` call sites so the component receives the dictionaries its keys name, in the active locale, as imports the bundler can trace and split.
```vue fileName="src/components/Footer.vue"
{{
t("footer.github")
}}
```
```ts fileName="What the compiler emits (simplified)"
import _dicHash_footer from "../.intlayer/dictionaries/footer.mjs";
import { useDictionary as useI18n } from "@intlayer/vue-i18n";
const { t } = useI18n(_dicHash_footer);
```
The component no longer reaches the global message tree. It reaches `footer`. That is why the component-size column below drops from 196 KB to 8 KB.
## What the adapter keeps, ignores, and does not replace
| `vue-i18n` API | With `@intlayer/vue-i18n` |
| ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `useI18n()` → `{ t, d, n, te, tm, rt, locale, availableLocales }` | ✅ Kept. `t` keys are typed against your dictionaries |
| `t("key", { name })`, `t("key", [a, b])`, `t("key", count)` | ✅ Kept. `{name}`, `{0}` and pipe-separated plurals resolve as before |
| `d(date, "long")`, `n(value, "currency")` | ✅ Kept. `datetimeFormats` / `numberFormats` from `createI18n()` are honored, backed by native `Intl` |
| `i18n.global.locale.value = "fr"` | ✅ Kept. A `WritableComputedRef` backed by Intlayer's client; reactivity behaves as before |
| `$t`, `$tc`, `$te`, `$tm`, `$rt`, `$d`, `$n`, `$i18n` (Options API) | ✅ Kept. Registered on `app.config.globalProperties` by `app.use(i18n)` |
| `v-t` directive | ✅ Kept |
| `legacy: true` | ✅ Accepted |
| `createI18n({ messages })` | ⚠️ `messages` are used as a **runtime fallback** with a dev warning. Remove the JSON imports for the bundle gains |
| `setLocaleMessage()`, `mergeLocaleMessage()` | ❌ Warn and do nothing. Runtime message loading is replaced by build-time dictionaries |
| SFC `` custom blocks | ❌ Not read. Move those messages into the locale JSON (or a `.content.ts` next to the component) |
| `@nuxtjs/i18n` | ⚠️ Separate adapter, see the [Nuxt compat doc](https://intlayer.org/doc/compatibility/nuxtjs-i18n) |
## The benchmark
### What was measured
The [Benchmark Bloom](https://github.com/intlayer-org/benchmark-bloom) suite builds **the same Vite + Vue 3 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`.
Both were built in the **static** configuration, the one most Vue projects ship: for `vue-i18n`, every locale's JSON imported and passed to `createI18n({ messages })`; for the adapter, the same components with `vite.config.ts` and `intlayer.config.ts` changed and the `messages` import removed. Native `vue-intlayer` is included for reference.
For each build, the suite records:
- **Lib size**: gzip (and minified) size of an empty component that only imports the i18n library.
- **Page JS**: gzip JavaScript downloaded per page, averaged over all pages and locales.
- **Locale leak %**: share of translated strings in the downloaded JS that belong to a locale the user is **not** viewing.
- **Page leak %**: share of translated strings 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.
- **E2E reactivity**: wall-clock time between selecting a new locale and `html[lang]` updating in the DOM (Playwright, 5 iterations).
- **Page load**: `PerformanceNavigationTiming.duration`.
> Numbers below come from the run dated **2026-09-12** with `vue-i18n` 11.4.0 and `@intlayer/vue-i18n` 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 Vite + Vue 3
| Setup | Strategy | Lib size (gz) | Lib size (min) | Page JS avg (gz) | Locale leak | Page leak | Component avg (gz) | E2E reactivity | Page load |
| ------------------------ | -------- | ------------: | -------------: | ---------------: | ----------: | --------: | -----------------: | -------------: | ---------: |
| **base** (no i18n) | - | 0.0 KB | 0.0 KB | 41.3 KB | 0.0% | - | 1.1 KB | 1.8 ms | 10.8 ms |
| `vue-i18n` | static | 24.3 KB | 83.2 KB | 134.9 KB | 50.0% | 90.0% | 196.0 KB | 2.8 ms | 13.6 ms |
| **`@intlayer/vue-i18n`** | static | **7.9 KB** | **23.2 KB** | **47.0 KB** | **15.0%** | **0.0%** | **8.4 KB** | **1.5 ms** | **9.3 ms** |
| `vue-intlayer` (native) | static | 3.9 KB | 11.1 KB | 57.1 KB | 56.8% | 0.0% | 7.7 KB | 4.5 ms | 13.8 ms |
| `vue-intlayer` (native) | dynamic | 3.9 KB | 11.1 KB | 59.8 KB | 50.0% | 0.0% | 6.5 KB | 4.0 ms | 15.8 ms |
> The base app's page-leak column is left blank: with no i18n library, the fingerprinting picks up hard-coded strings in shared chunks and the number is not meaningful.
**How to read it**
- **88 KB less per page, same components.** `vue-i18n` takes the 41.3 KB app to **134.9 KB**. The adapter build of the same components lands at **47.0 KB**, 5.7 KB over the base app. Most of the difference is the 74.9 KB of `src/locales` that `createI18n({ messages })` pulls into every page and the adapter never bundles as a block.
- **The runtime shrinks 3x.** An empty component that only imports `vue-i18n` costs **24.3 KB gzip / 83.2 KB minified**: `@intlify/core-base`, the message compiler and the runtime. The adapter costs **7.9 KB / 23.2 KB**, most of it Intlayer's core plus the `vue-i18n` API surface.
- **Components: 23x smaller.** A `useI18n()` component compiled in isolation averages **196 KB**, because `t` is bound to the instance that holds every message of every locale. With the adapter, the same component averages **8.4 KB**: it reaches its own dictionary.
- **Leakage.** `vue-i18n` ships every locale and every page's strings on every page: 50% locale leakage (on the two fingerprinted locales; with ten locales bundled the real waste is higher), 90% page leakage. The adapter drops page leakage to **0%** because each component only imports its dictionaries. Locale leakage sits at 15% in this `static` run; `importMode: 'dynamic'` is the setting that removes it, and that configuration was not part of this Vue run.
- **Reactivity and page load.** Locale switching is cheap for both (1.5-2.8 ms); Vue's reactivity system makes it so once messages are in memory. Page load goes from 13.6 ms to **9.3 ms**, in line with 88 KB less JavaScript to parse.
- **About the native rows.** `vue-intlayer` in this run bundled every locale in `static` mode and landed at 57.1 KB with a 3.9 KB runtime; the adapter's synced dictionaries carried fewer foreign-locale strings, hence the lower per-page figure. The native runtime remains the lightest of the three, and its `.content.ts` model is where SFC `` blocks find their equivalent.
## Why the numbers move
Nothing in `src/components/` changed, so the gains come from what `useI18n` is bound to.
**With `vue-i18n`**, the binding is the global instance. `createI18n({ messages: { en, fr, ... } })` is one import that holds everything; every component that calls `useI18n()` can reach all of it, so the bundler cannot split below the instance. Optimizing means _you_ split `en.json` by route, call `setLocaleMessage()` in a router guard, and keep the route-to-file map correct as components move.
```bash
.
├── locales
│ ├── en.json # every page's strings
│ └── fr.json
└── src
├── i18n.ts # createI18n({ messages: { en, fr, ... } })
├── main.ts # app.use(i18n)
└── components
└── Footer.vue # useI18n(); t("footer.github")
```
**With `@intlayer/vue-i18n`**, the binding is the dictionary. `syncJSON` turns each top-level key of `en.json` into a dictionary; the optimize pass hands the component the ones its keys name, as imports the bundler traces and splits per page.
```bash
.
├── intlayer.config.ts # syncJSON({ format: "vue-i18n", source: ... })
├── locales
│ ├── en.json # unchanged, still the source of truth
│ └── fr.json
├── .intlayer/ # generated: one dictionary per top-level key, per locale
└── src
├── i18n.ts # createI18n({}) ← messages import removed
├── main.ts # app.use(i18n) ← unchanged
└── components
└── Footer.vue # useI18n(); t("footer.github") ← unchanged
```
The `messages` import in `i18n.ts` is the one line to delete. That is the 88 KB.
## 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 `vue-i18n`, installs `intlayer`, `vue-intlayer`, `@intlayer/vue-i18n` and `@intlayer/sync-json-plugin`, and pre-fills `intlayer.config.ts`. Keep `vue-i18n` installed: it is a peer dependency 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",
format: "vue-i18n",
},
plugins: [
syncJSON({
// vue-i18n dialect: {name}, {0}, "car | cars"
format: "vue-i18n",
source: ({ locale }) => `./locales/${locale}.json`,
location: "locales",
}),
],
};
export default config;
```
`locales/{locale}.json` stays where it is. Each top-level key (`footer`, `hero`...) becomes a dictionary.
```ts fileName="vite.config.ts"
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import { vueI18nVitePlugin } from "@intlayer/vue-i18n/plugin";
export default defineConfig({
plugins: [vue(), vueI18nVitePlugin()],
});
```
```ts fileName="src/i18n.ts"
import { createI18n } from "vue-i18n";
// Before: createI18n({ locale: "en", messages: { en, fr, es } })
export const i18n = createI18n({ locale: "en" });
```
`vueI18nVitePlugin()` wraps `vite-intlayer` (content watching, dictionary compilation, the optimize pass) and aliases `vue-i18n` to the adapter. Removing the `messages` import is what drops the 88 KB; leaving it in keeps the app working but ships both.
### What you can delete afterwards
| File / pattern | Why |
| ------------------------------------------------ | ---------------------------------------------------------------------------- |
| `import en from "./locales/en.json"` and friends | Used only as a fallback by the adapter. This is where the 88 KB was |
| `setLocaleMessage()` in router guards | No-op. Per-route loading is the compiler's job now |
| `@intlify/unplugin-vue-i18n` | Not needed: it precompiles messages and SFC blocks the adapter does not read |
| SFC `` blocks | Not read; move them to the locale JSON or to a `.content.ts` per component |
### What you gain beyond bytes
- **Typed keys.** `t("footer.github")` is typed against the compiled `footer` dictionary; a wrong path is a TypeScript error instead of the key rendered as text.
- **`npx intlayer test`** fails CI on a missing key in any locale. **`npx intlayer fill`** translates the missing ones with your own provider key (OpenAI, Anthropic, Mistral, Gemini...) and writes them back into `locales/{locale}.json`.
- **Visual Editor and CMS** operate on the same JSON, so non-developers edit through a UI and the files update.
- **Incremental move to `.content.ts`.** Any component can switch from `useI18n()` to `useIntlayer("footer")` with a co-located content file. JSON and `.content.ts` dictionaries coexist and merge.
## Limits to know before you start
- **SFC `` blocks are not read.** If your messages live inside components, they need to move to the locale files (or to `.content.ts`, which is the same idea with types).
- **Runtime message loading is gone.** `setLocaleMessage()` and `mergeLocaleMessage()` warn and return. Translations fetched from a CMS at runtime need Intlayer's CMS, or the `intlayer pull` / `push` commands.
- **`messages` is a fallback, not free.** Keeping the JSON imports in `createI18n()` keeps the 75 KB in the bundle. Delete them once `intlayer test` passes.
- **The adapter is not the native runtime.** 7.9 KB against 3.9 KB for `vue-intlayer`. Once every component has moved to `useIntlayer`, drop it.
## When to use which?
- **Stay on `vue-i18n`** if your app depends on SFC `` blocks, on runtime `setLocaleMessage()` flows, or if 90 KB per page is not a concern for your audience.
- **Use `@intlayer/vue-i18n`** if you are on `vue-i18n` and want the 88 KB, the 23x smaller components, 0% page leakage, typed keys and CI checks without editing a `.vue` file. This is the entry point for an existing `vue-i18n` codebase.
- **Go native (`vue-intlayer`)** for new projects, or once the adapter has done its job. It has the lightest runtime (3.9 KB) and the per-component `.content.ts` model that replaces `` blocks with typed content.
## Related comparisons
- [vue-i18n vs Intlayer](https://intlayer.org/blog/vue-i18n-vs-intlayer) (features and DX)
- [vue-i18n vs Intlayer benchmark](https://intlayer.org/blog/vue-i18n-vs-intlayer-benchmark) (the libraries, same benchmark)
- [next-intl vs @intlayer/next-intl](https://intlayer.org/blog/next-intl-vs-intlayer-next-intl) (same adapter series)
- [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)
- [Migration guide: vue-i18n to Intlayer](https://intlayer.org/doc/migration/vue-i18n)
- [Compat adapter reference: vue-i18n](https://intlayer.org/doc/compatibility/vue-i18n), [Nuxt i18n](https://intlayer.org/doc/compatibility/nuxtjs-i18n)
## Conclusion
`@intlayer/vue-i18n` changes what `useI18n()` is bound to: from a global instance holding every message of every locale to a dictionary compiled for that component. On the same Vite + Vue 3 app that is **88 KB less per page**, a **3x smaller runtime**, **23x smaller components** and **0% page leakage**, for a config file, a plugin line and one deleted import. SFC `` blocks and runtime message loading are the two things it does not carry, and the native `vue-intlayer` runtime remains half its size.
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.