--- createdAt: 2026-09-16 updatedAt: 2026-09-16 title: "How to pick the right Vue i18n library in 2026" description: A decision guide for Vue and Nuxt internationalization. Which questions to answer before comparing vue-i18n, @nuxtjs/i18n, fluent-vue, Paraglide and Intlayer, and what each choice costs in bundle size, typing and SSR payload. keywords: - vue i18n - vue internationalization - vue-i18n - nuxt i18n - fluent-vue - Paraglide - Intlayer - i18n library comparison slugs: - blog - how-to-pick-vue-i18n-library author: aymericzip --- # How to pick the right Vue i18n library "Vue i18n" is both a generic term and the name of the library nearly everyone installs. That is convenient and misleading at the same time: `vue-i18n` is a fine default, but it is not the only option, and the questions that should drive the choice (SSR or not, how many pages, who writes the translations) are rarely asked before `npm install`. This guide asks them first, then maps the answers to the libraries that fit, for plain Vite + Vue and for Nuxt. ![Vue i18n library ecosystem](https://github.com/aymericzip/intlayer/blob/main/docs/assets/cloud_i18n_logo.png?raw=true) ## Table of Contents ## Six questions to answer before comparing libraries 1. **Vite SPA or Nuxt?** In an SPA the catalog cost is a JS bundle problem. In Nuxt it is also an HTML payload problem, because the messages are serialized into the SSR state and hydrated. Most "vue-i18n is slow" reports come from Nuxt apps for this reason. 2. **Who writes the translations?** Developers, a TMS, an agency delivering ICU strings, or an AI pipeline. `vue-i18n` uses its own pipe-separated plural syntax, not ICU. That matters if strings come from outside. 3. **How many locales and pages?** Two locales and five pages can ship everything. Ten locales and forty routes cannot, and the loading strategy becomes the main cost. 4. **Do you need types on keys?** `t("cart.totl")` compiles in `vue-i18n` unless you pass a message schema generic, and that schema fights with lazily loaded catalogs. 5. **What does the content contain?** UI labels only, or markdown, links inside sentences, and per-locale blocks. Rich content is where `t()` returning a string gets awkward. 6. **Is CSP a constraint?** The default `vue-i18n` build compiles messages in the browser with `new Function`. Runtime-only builds need `@intlify/unplugin-vue-i18n` to precompile at build time. Write the answers down. Everything below refers back to them. ## The landscape in one picture The Vue ecosystem has fewer i18n libraries than React, and they come from different architectural waves. ![History of JavaScript i18n libraries](https://github.com/aymericzip/intlayer/blob/main/docs/assets/history_i18n.png?raw=true) `vue-i18n` appeared in 2015 and has been the default ever since. `@nuxt/i18n` wraps it with locale routing, SEO tags and lazy loading per locale. Messages are compiled to render functions, at build time if you add the unplugin, in the browser otherwise. Mozilla Fluent `.ftl` files brought a friendlier message syntax with grammar-aware variants. No key types, and the Vite plugin loads every locale into every page. Paraglide generates one function per message and lets the bundler tree-shake the rest. Intlayer declares content per component in `.content.ts` files, generates types, and ships only what a route renders. The [history of JavaScript i18n](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/history_of_i18n.md) covers each wave in detail. ## The decision that matters most: where content lives and when it loads Two structural choices explain most of the bundle difference between setups: - **Centralized or scoped content.** One `locales/en.json` for the app, or one declaration per component. - **Static or dynamic import.** Everything at startup, or the active locale (and ideally the active route) fetched on demand. The graph estimates the payload for a theoretical app of 1 to 10 pages, translated into 1 to 10 locales, with about 30 KB of text per page. ![Theoretical content leakage by architecture](https://github.com/aymericzip/intlayer/blob/main/docs/assets/theorical_content_leakage.png?raw=true) `vue-i18n` supports the dynamic axis: `setLocaleMessage` after an `import()` means you stop shipping nine locales nobody reads. What it does not give you is the page axis. A locale catalog is one object, and loading it loads every page's copy. In an SPA nobody notices. In Nuxt, with `@nuxtjs/i18n` and more than ten pages, every route carries the strings of every other route, twice: in the JS chunk and in the SSR payload. The [Vue benchmark](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/vue.md) measures this as "leakage from other routes" and "leakage from other locales". If your answer to question 3 was "many pages", this section outweighs any API preference. The [per-component vs centralized i18n](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/per-component_vs_centralized_i18n.md) post covers the maintenance side of the same trade-off. ## The candidates Library sizes are from the [Vue benchmark](https://github.com/aymericzip/intlayer/blob/main/docs/docs/en/benchmark/vue.md): plugin plus composable in an empty component, after bundling, tree-shaking and minification, on a 10-page, 10-locale app. Content is measured separately. | Library | Content model | Types on keys | Message format | Per-route splitting | Library size | | :------------- | :-------------------------------------------------------- | :-------------------------- | :----------------- | :------------------ | :----------- | | `vue-i18n` | Central catalogs per locale, optional SFC `` blocks | Opt-in via a schema generic | Own (pipe plurals) | No | ~24.3 kB | | `@nuxtjs/i18n` | Same as `vue-i18n`, plus routing and SEO tags | Same | Same | No, per locale only | On top | | `fluent-vue` | `.ftl` files (Mozilla Fluent) | None | Fluent | No | ~29.7 kB | | Paraglide | inlang project, generated functions | Generated | Own | Via tree-shaking | Near zero | | Intlayer | One `.content.ts` per component | Generated, on by default | Helpers (`plural`) | Yes, per component | Baseline | > Numbers are a snapshot at the benchmark's versions. Run it on your own app before deciding on size alone. Paraglide's near-zero library size is by construction: the runtime is generated into your repository, which means a regeneration step before every push and merge conflicts on generated files. Intlayer needs `vite-intlayer` (or the Nuxt module), so it cannot run without a build step. ## Match your answers to a library `vue-i18n` in Composition mode (`legacy: false`), with `@intlify/unplugin-vue-i18n` so you ship the runtime-only build. Lazy-load locales with `import()`. That covers most small apps and the community answers are everywhere. SFC `` blocks colocate messages with the component, which helps, but the extraction and TMS tooling around them is thinner than around JSON catalogs, so decide early which one the team uses. `@nuxtjs/i18n` gives you the routing strategy, the `hreflang` tags and the locale detection with no code, and that alone justifies it for content sites with a handful of pages. Its limit is the per-locale catalog: past ten or so pages the SSR payload carries every route's copy. If that is your case, either hand-wire `vue-i18n` with per-route messages, or move to scoped content. The [Nuxt i18n post](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/list_i18n_technologies/frameworks/nuxt.md) walks through the routing strategy choice first. `vue-i18n`'s plural syntax (`"no item | one item | {count} items"`) is not ICU and is not portable. Translators need to be told about it, and a TMS export will not produce it. Either agree on the format before the first catalog exists, or pick a library whose format matches your vendor. Intlayer's ICU support is partial, so if you receive ICU strings today, treat that as a blocker too. Prefer scoped content compiled at build time. Paraglide gets there through tree-shaking, which works as advertised on Vite. Intlayer gets there through per-component declarations and ships only what the route renders. With `vue-i18n`, you can split messages by route by hand, but nothing enforces it and a shared component importing a global namespace quietly undoes it. `vue-i18n` can be typed by passing a schema generic to `createI18n`. It works, and it breaks the moment catalogs are lazily loaded, because the schema describes messages that may not be there yet. If you do not want to maintain that, pick a library whose types are generated from the content: Paraglide or Intlayer. The [detecting missing translations](https://github.com/aymericzip/intlayer/blob/main/docs/blog/en/detecting_missing_translations.md) post compares what each catches at build time. Markdown pages, sentences with a `` in the middle, per-locale components. `vue-i18n` has `` for component interpolation, which works and is verbose. Intlayer's content nodes accept markdown, HTML and nested objects directly, which fits better when the app is content-heavy. Then the centralized JSON has no consumer left to justify it. Colocated content plus a CLI that fills the missing locales is the shorter path. Intlayer's `fill` command runs against your own API key (OpenAI, Anthropic, Mistral, Gemini) and only re-translates what changed. ## Where each library falls short - **`vue-i18n`**: heaviest of the set, own plural format, types are opt-in and fragile with lazy loading, no per-route scoping, dead keys accumulate silently. Leaving `legacy: true` in a Vue 3 app keeps the Vue 2 compatibility layer and loses `useI18n()` typing. - **`@nuxtjs/i18n`**: inherits everything above, and the SSR payload carries every page's strings once past a dozen routes. - **`fluent-vue`**: nice message syntax, no key types, and the Vite plugin loads all content in all languages into every page. Heaviest in the benchmark. - **Paraglide**: generated files committed to the repo, regeneration before every push, and the locale is read from cookie or storage on each message call rather than from a reactive store, which costs work on locale change. - **Intlayer**: mandatory build plugin, smaller ecosystem, partial ICU support, and content spread across the codebase by design, so exporting one JSON for a translator needs tooling. ## What each option looks like in code The same component, a cart summary with a title and a plural, written with each candidate. The interesting part is not the template, it is where the content lives and what `vue-tsc` knows about it. ```json fileName="src/locales/en.json" { "cart": { "title": "Your cart", "items": "no item | one item | {count} items" } } ``` ```vue fileName="src/components/CartSummary.vue" ``` Pipe-separated plurals are vue-i18n's own format, not ICU. `t` accepts any string unless you pass a message schema generic to `createI18n`. ```ftl fileName="src/locales/en.ftl" cart-title = Your cart cart-items = { $count -> [one] { $count } item *[other] { $count } items } ``` ```vue fileName="src/components/CartSummary.vue" ``` Fluent's syntax handles plurals and grammatical variants well. Message ids are untyped strings, and the Vite plugin bundles every locale into every page. ```json fileName="messages/en.json" { "cart_title": "Your cart", "cart_items": "{count} items" } ``` ```vue fileName="src/components/CartSummary.vue" ``` Every message is a generated, typed function, so a missing key is an import error. The `paraglide/` folder is generated into your repo and regenerated on every change. ```ts fileName="src/components/cartSummary.content.ts" import { plural, t, type Dictionary } from "intlayer"; const cartSummaryContent = { key: "cart-summary", content: { title: t({ en: "Your cart", fr: "Votre panier", es: "Tu carrito" }), items: t({ en: plural({ one: "{{count}} item", other: "{{count}} items" }), fr: plural({ one: "{{count}} article", other: "{{count}} articles" }), es: plural({ one: "{{count}} artículo", other: "{{count}} artículos" }), }), }, } satisfies Dictionary; export default cartSummaryContent; ``` ```vue fileName="src/components/CartSummary.vue"