Vue I18n Composition API Guide
Vue I18n’s Composition API replaces the legacy global $t mixin with a useI18n() composable backed by createI18n({ legacy: false }), and the most common failure is a [intlify] Not found 'some.key' for locale 'en' warning when scope and message registration disagree. This guide walks the full setup: initializing the plugin, choosing global versus local scope, wiring fallbackLocale, using single-file-component <i18n> blocks, and running it under the Nuxt i18n module. It targets senior front-end engineers migrating a Vue 3 app off the Options-API translation pattern and onto reactive, tree-shakeable composables.
The Composition API mode (legacy: false) is the only mode that exposes a real useI18n() hook returning reactive refs. In legacy mode useI18n() throws, and locale is a plain string rather than a Ref<string>. Getting this flag right at createI18n time determines every downstream pattern on the page, so we start there and build outward to scope resolution, fallback behavior, and framework variants.
Prerequisites
Concept & spec — what legacy: false actually changes
createI18n has two operating modes. Legacy mode (legacy: true, the v8-compatible default) installs a global mixin that injects $t, $tc, and $d onto every component instance and exposes this.$i18n. Composition mode (legacy: false) installs nothing on the instance prototype; instead useI18n() becomes callable inside setup() and returns an object of reactive refs and functions. This is the mode the maintainers recommend for all new code, and it is the only mode where locale is a Ref<string> you can watch.
Vue I18n’s message syntax is its own format, but it leans on the same CLDR plural rules and ECMA-402 Intl objects that the rest of the platform uses — $d/d() wrap Intl.DateTimeFormat, $n/n() wrap Intl.NumberFormat, and plural selection follows the CLDR plural categories. When you need true ICU {count, plural, ...} syntax rather than Vue’s pipe-delimited form, you wire the @intlify/message-compiler or hand off to the ICU message format rules directly. This page sits within Framework i18n & Component Routing, the area covering how each framework wires translation into its render and routing layer.
The reactive contract matters for one practical reason: assigning i18n.global.locale.value = 'de' re-renders every component bound to a t() call. In legacy mode you would assign i18n.global.locale = 'de' (no .value). Mixing the two is the single most common migration bug, and it surfaces as “the locale switched in devtools but the UI didn’t update.”
There is a second, subtler consequence of Composition mode: tree-shaking. The legacy $t mixin attaches to every component whether or not it translates anything, so the full message formatter ships in the bundle. With legacy: false plus the @intlify/unplugin-vue-i18n compiler, components that never call useI18n() pull in nothing, and JSON messages are pre-compiled to functions at build time rather than parsed at runtime. For a large app this is the difference between shipping the message compiler to the browser and shipping only the small runtime — measure it with import.meta.env flags __VUE_I18N_FULL_INSTALL__ and __INTLIFY_PROD_DEVTOOLS__, both of which you set to false in production to drop devtools and legacy-install code paths entirely.
A note on terminology before the steps: Vue I18n calls the createI18n return value the i18n instance, exposes the app-wide store as i18n.global, and lets each useI18n() call open either a window onto that global store or a private composer. Keeping those three nouns straight — instance, global composer, local composer — makes the scope section below far easier to reason about.
Step-by-step implementation
1. Create the i18n instance in Composition mode
Configure createI18n once and export the instance. Setting legacy: false is non-negotiable for the Composition API; globalInjection: false keeps $t off the template so the team is forced onto composables.
// src/i18n.ts
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import de from './locales/de.json'
export const i18n = createI18n({
legacy: false, // enables useI18n()
globalInjection: false, // no global $t in templates
locale: 'en',
fallbackLocale: 'en',
messages: { en, de },
})
2. Install the plugin on the app
app.use(i18n) registers the instance so useI18n() can find it via injection. Without this, useI18n() throws Must be called at the top of a setup function.
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n'
createApp(App).use(i18n).mount('#app')
3. Consume translations with useI18n()
Inside <script setup>, destructure t and locale. The locale ref is writable — assigning to locale.value switches the active locale globally when the call uses global scope.
{{ t('home.title') }}
4. Pluralize and format with the same composable
t takes a count for pluralization; n and d format numbers and dates through Intl. Vue plurals use the | separator, choosing the branch by CLDR category for the active locale.
// en.json: { "cart": { "items": "no items | one item | {count} items" } }
const { t, n } = useI18n()
t('cart.items', 0) // "no items"
t('cart.items', 5) // "5 items" (count interpolated)
n(1234.5, 'currency') // "$1,234.50" via Intl.NumberFormat
5. Sync the active locale to your router
Bind locale.value to the route so a URL like /de/checkout resolves deterministically. Keep this in a navigation guard so refreshes and deep links stay correct.
router.beforeEach((to) => {
const target = (to.params.locale as string) || 'en'
if (i18n.global.locale.value !== target) {
i18n.global.locale.value = target // .value — Composition mode
}
})
Configuration reference
| Option | Type | Description / default |
|---|---|---|
legacy |
boolean |
true installs the Options-API mixin; set false to enable useI18n(). Default true. |
locale |
string |
Active locale tag (BCP 47, e.g. de-AT). Default 'en-US'. |
fallbackLocale |
string | string[] | object | false |
Locale(s) tried when a key is missing. Supports per-locale maps. Default false. |
messages |
Record<Locale, MessageSchema> |
Global message catalog keyed by locale. Default {}. |
globalInjection |
boolean |
Injects $t/$d/$n into templates even in Composition mode. Default true. |
missingWarn |
boolean | RegExp |
Emit console warning on a missing key. Default true. |
fallbackWarn |
boolean | RegExp |
Warn when a key resolves only via fallback. Default true. |
useScope |
'global' | 'local' |
Argument to useI18n(), not createI18n. Selects which message store the composable reads. Default 'global'. |
inheritLocale |
boolean |
Local-scope instances follow the global locale. Default true. |
Global vs local scope
Scope is chosen per useI18n() call, and it decides which message store the returned t reads from. Global scope (useScope: 'global', the default) reads the catalog passed to createI18n and shares one writable locale ref across the app — switching it re-renders everything. Local scope (useScope: 'local') creates a component-private message store, typically fed by an SFC <i18n> block, and by default inherits the global locale via inheritLocale: true.
{ "en": { "label": "Save" }, "de": { "label": "Speichern" } }
The trap: with local scope, writing locale.value = 'de' from that component only changes the local locale unless inheritLocale keeps it linked — set inheritLocale: false deliberately when a widget needs to render in a fixed locale (a language picker previewing each option, for example). For the precise resolution order when a local key is absent, the lookup falls through to the global store and then the fallback chain, exactly as the diagram above shows.
When should you reach for local scope at all? The honest answer is rarely. Global scope keeps one source of truth and one writable locale, which is what most apps want. Local scope earns its keep in three situations: a reusable component published as a library that ships its own translations and must not depend on the host app’s catalog; a self-contained widget whose copy you want colocated in the SFC for review; and the fixed-locale preview case above. Outside those, defaulting every component to useScope: 'local' fragments your catalog across dozens of <i18n> blocks and makes CI key-parity checks much harder to run, because the extractor must now walk SFC blocks in addition to the central JSON files.
One more detail that bites teams: local-scope t() does not silently read global keys unless the local store misses entirely. If a component declares an <i18n> block with an en object but you reference a key that only exists globally, resolution does fall through — but if the local block defines the same top-level namespace with different children, the local definition shadows the global one for that namespace. Keep local blocks scoped to component-only keys to avoid shadowing surprises.
Fallback locale behavior
fallbackLocale controls what t() returns when a key is missing in the active locale. It accepts a single tag, an ordered array, a false to disable, or a per-locale object for region-aware chains:
fallbackLocale: {
'de-AT': ['de', 'en'], // Austrian German → German → English
'pt-BR': ['pt', 'en'],
default: ['en'],
}
Two warnings govern the experience. fallbackWarn fires when a key resolves only through fallback — useful in development, noisy in production. missingWarn fires when even the fallback chain is exhausted, at which point t() returns the key string itself. Silencing these globally hides real gaps, so scope them with a RegExp (e.g. missingWarn: /^(?!chart\.)/ to ignore a known-dynamic namespace) rather than setting false. When the not-found warning is the symptom you are chasing, the dedicated walkthrough for the Vue I18n fallback “not found key” warning covers the root causes in detail.
Design the chain to mirror the language taxonomy, not the order locales were added. Region-specific tags should fall back to their base language before crossing to a different language: de-AT → de → en, never de-AT → en. Dropping the base-language hop means an Austrian user with a missing Austrian-specific string gets English instead of standard German, which is almost always worse. The per-locale object form makes this explicit and auditable; the array form applies the same chain to every locale, which is fine only when all your locales share one ultimate fallback. If you maintain regional variants, model the fallback as data and keep it next to the locale-negotiation logic so both stay in sync.
Framework variants
Nuxt (@nuxtjs/i18n)
The Nuxt i18n module wraps Vue I18n and owns createI18n for you. You configure it in nuxt.config.ts and supply the i18n options through a i18n.config.ts file; the module handles SSR-safe locale detection, route prefixing, and <NuxtLink> localization.
// i18n.config.ts — returned object is passed to createI18n
export default defineI18nConfig(() => ({
legacy: false,
fallbackLocale: 'en',
}))
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: { locales: ['en', 'de'], defaultLocale: 'en', strategy: 'prefix_except_default' },
})
Because Nuxt renders on the server, never read navigator.language at config time — let the module’s detectBrowserLanguage handle negotiation so server and client agree, avoiding the same hydration class of bug seen in Next.js App Router middleware.
Plain Vite SPA
A client-rendered Vite app uses the src/i18n.ts and main.ts setup from steps 1–2 verbatim. Add @intlify/unplugin-vue-i18n to compile SFC <i18n> blocks and pre-compile JSON messages so the runtime ships without the full compiler:
// vite.config.ts
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
export default { plugins: [VueI18nPlugin({ include: './src/locales/**' })] }
Single-file-component <i18n> blocks
Colocating translations inside the component with an <i18n> block pairs naturally with local scope. The block accepts lang="json", lang="yaml", or an external src reference, and the @intlify/unplugin-vue-i18n plugin compiles it at build time. Use <i18n global> to merge the block into the global catalog instead of a private store:
en:
badge: New
de:
badge: Neu
Two cautions: the compiler plugin is mandatory — without it the <i18n> block is stripped and the keys silently vanish — and merged global blocks make it harder to see your full catalog in one place, so reserve <i18n global> for genuinely component-owned copy.
Migrating from Vue 2 / Options API
If the codebase still calls this.$t and this.$i18n.locale = 'de', those break the moment you flip legacy: false because .value is now required and the mixin is gone. Run the two modes in parallel during transition rather than big-bang — the step-by-step path is in migrating Vue 2 i18n to the Vue 3 Composition API.
Verification
Assert lookups in a unit test with @vue/test-utils and a real i18n instance, then guard key parity in CI:
// i18n.spec.ts
import { mount } from '@vue/test-utils'
import { i18n } from '../src/i18n'
import Greeting from '../src/components/Greeting.vue'
test('renders fallback when key missing in de', () => {
i18n.global.locale.value = 'de'
const wrapper = mount(Greeting, { global: { plugins: [i18n] } })
expect(wrapper.text()).not.toContain('greeting.title') // not the raw key
})
# CI gate: fail the build if any locale lacks a key present in en
npx vue-i18n-extract report --vueFiles './src/**/*.vue' \
--languageFiles './src/locales/*.json' --add false
Expected output lists missingKeys as empty. A non-empty missingKeys array exits non-zero and blocks the merge.
Common pitfalls
useI18n() must be called at the top of a setup function— you called it conditionally or outsidesetup/<script setup>. Move it to the top level of the component.- Locale switches in devtools but UI is frozen — you assigned
i18n.global.locale = 'de'instead of.value. In Composition modelocaleis a ref. - Local
<i18n>keys not found — the SFC compiler plugin isn’t installed; add@intlify/unplugin-vue-i18nor the<i18n>block is ignored. Not found 'x.y' for locale 'en'noise — usually a scope mismatch; the key lives in a global catalog but the component useduseScope: 'local'. Trace it with the Vue I18n fallback warning walkthrough.- Wrong plural branch in Arabic/Russian — Vue’s
|plurals only cover a few categories; for languages with 4–6 CLDR forms see pluralization across languages. - SSR hydration mismatch on locale — the server and client picked different locales; pin detection to the URL, not the browser header, at render time.
FAQ
Do I have to set legacy: false to use useI18n()?
Yes. useI18n() only works in Composition mode. With legacy: true (the default for v8 compatibility), the composable throws and you must use the $t mixin instead. Set legacy: false in createI18n for any new Vue 3 project.
Why does locale.value = 'de' work but locale = 'de' doesn’t?
In Composition mode locale is a Vue Ref<string>, so you mutate it through .value. Assigning the bare variable just rebinds a local reference and never reaches the reactive store, so no re-render happens. Legacy mode used a plain string, which is the source of most migration confusion.
What’s the difference between global and local scope in useI18n()?
Global scope (useScope: 'global', default) reads the catalog from createI18n and shares one app-wide locale. Local scope (useScope: 'local') gives the component a private message store, usually from an SFC <i18n> block, and by default inherits the global locale via inheritLocale: true.
How do I stop the console “Not found key” warnings without hiding real gaps?
Scope missingWarn and fallbackWarn to a RegExp that excludes only known-dynamic namespaces rather than setting them to false. Disabling them entirely masks genuinely untranslated keys that should fail CI through vue-i18n-extract.
Does Nuxt need a separate createI18n call?
No. The @nuxtjs/i18n module calls createI18n internally. You pass options via i18n.config.ts (which still needs legacy: false) and configure routing in nuxt.config.ts. Calling createI18n yourself in a Nuxt app double-initializes the plugin.
Related
- Migrating Vue 2 i18n to Vue 3 Composition API — the parallel-mode upgrade path off the
$tmixin. - Vue I18n fallback “not found key” warning — diagnosing scope and registration mismatches behind the warning.
- Fallback chain configuration — designing region-to-language fallback maps that
fallbackLocaleconsumes. - Pluralization rules across languages — when Vue’s
|plural form is too coarse for CLDR categories. - Next.js i18n routing setup — a server-routed contrast to Vue’s client-reactive locale model.
Part of Framework i18n & Component Routing.