Migrating vue-i18n v8 (Vue 2) to v9 (Vue 3 Composition API)
Upgrading vue-i18n from v8 to v9 breaks every this.$t() and this.$i18n call because Vue 3 removed the Vue.prototype injection those APIs depended on, leaving you with TypeError: this.$t is not a function at runtime. The v9 release replaces the global constructor with a createI18n() factory, splits behaviour behind a legacy flag, and ships two builds (with and without the runtime message compiler) that behave differently under a Content-Security-Policy. This page walks the exact migration path: stand up the v9 instance in legacy: true bridge mode so the app keeps booting, then move components to useI18n() one at a time, and finally flip to legacy: false.
The trap most teams hit is treating the upgrade as a version bump in package.json. It is a behavioural migration: the message format, the injection model, and the compiler-vs-runtime split all changed at once, so a big-bang switch produces a wall of Not found 'key' warnings and CSP eval violations with no obvious cause.
Root cause: what actually changed between v8 and v9
Three separate breaking changes land in the same release, and conflating them is why migrations stall.
1. No more prototype injection. vue-i18n v8 ran Vue.use(VueI18n) and patched Vue.prototype.$t, $tc, $te, and $i18n onto every component instance. Vue 3 deliberately removed Vue.prototype as an extension point, so v9 instead exposes a createI18n() factory and a useI18n() composable. In legacy: false mode the $t family does not exist at all; calling it throws TypeError: this.$t is not a function.
2. The Composition API surface returns refs, not plain values. useI18n() returns locale, t, messages, and friends, but locale is a Ref. Code ported from v8 that assigns this.$i18n.locale = 'es' becomes locale.value = 'es' — assigning to locale directly replaces the ref and silently kills reactivity.
3. The message compiler moved behind a CSP-sensitive build. v9 compiles ICU-style message strings to functions. The default vue-i18n.esm-bundler.js build includes the compiler, which uses new Function() — blocked by any CSP without 'unsafe-eval', surfacing as EvalError: Refused to evaluate ... unsafe-eval. The fix is to precompile messages at build time and alias to the runtime-only build, vue-i18n.runtime.esm-bundler.js.
Bridge mode (legacy: true) re-enables the $t family by injecting a compatibility layer, so the app runs unchanged on v9 while you migrate component-by-component. It is the spec-sanctioned escape hatch, not a permanent state.
Minimal reproducible example: the failure
Bumping the dependency and booting immediately produces the failure. This v8-era component compiles but throws the moment it renders under v9 with legacy: false:
{{ $t('welcome.message') }}
With a strict CSP the message compiler adds a second, separate failure even when you do use t():
EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval'
is not an allowed source of script in the following Content-Security-Policy directive
Both errors point at the same root: v9 changed the contract and the old code never opted into it.
The fix: three-stage incremental migration
Stage 1 — boot v9 in legacy bridge mode
Install v9 and replace the v8 constructor with the factory, but keep legacy: true. The $t family keeps working, so the whole app boots on the first commit. This mirrors the bridge pattern documented in the Vue i18n Composition API Guide and gives you a green build to migrate from.
// src/i18n/index.ts
import { createI18n } from 'vue-i18n';
import en from './locales/en.json';
import es from './locales/es.json';
export const i18n = createI18n({
legacy: true, // bridge mode: this.$t / this.$i18n keep working
globalInjection: true, // keep $t available in templates during migration
locale: 'en',
fallbackLocale: 'en',
messages: { en, es },
});
// src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { i18n } from './i18n';
const app = createApp(App);
app.use(i18n); // must run before mount, or context is undefined
app.mount('#app');
Stage 2 — migrate components to useI18n()
With the app booting, convert components one at a time. Replace this.$t with the destructured composable inside <script setup>, and — critically — assign through .value on the locale ref. Each converted component is an independent, small PR.
{{ t('welcome.message') }}
Stage 3 — flip legacy: false and harden the compiler
Once no component references this.$t, set legacy: false to remove the global injection layer entirely, and alias to the runtime-only build so the message compiler never ships to the browser. Precompiling messages keeps the page CSP-clean — no 'unsafe-eval' needed.
// vite.config.ts — alias to the runtime-only build (no in-browser compiler)
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import vueI18n from '@intlify/unplugin-vue-i18n/vite';
export default defineConfig({
resolve: {
alias: {
// runtime build: messages must be precompiled, so no new Function() at runtime
'vue-i18n': 'vue-i18n/dist/vue-i18n.runtime.esm-bundler.js',
},
},
plugins: [
vue(),
vueI18n({ compositionOnly: true, runtimeOnly: true }), // precompile .json messages
],
});
// src/i18n/index.ts — final form
export const i18n = createI18n({
legacy: false, // global $t injection gone; useI18n() only
globalInjection: false,
locale: 'en',
fallbackLocale: 'en',
fallbackWarn: false, // silence partial-resolution noise; see the fallback page below
messages: { en, es },
});
If a deeply nested component still needs $t in templates after cutover, that component was missed in Stage 2 — find it before flipping the flag, not after.
Verification: prove the migration is complete
Static-scan for any surviving prototype calls before flipping legacy: false. A non-empty result means Stage 2 is unfinished:
# Fails CI if any this.$t / this.$tc / this.$i18n remains
grep -rEn '\$t[ce]?\(|\$i18n' src/ --include='*.vue' --include='*.ts' \
&& echo "Legacy injection still present — do not flip legacy:false" && exit 1 \
|| echo "No prototype i18n usage — safe to cut over"
Then assert the runtime build carries no compiler, which is what keeps the page CSP-safe:
// i18n.spec.ts
import { describe, it, expect } from 'vitest';
import * as vueI18n from 'vue-i18n';
describe('vue-i18n runtime build', () => {
it('ships without the runtime message compiler', () => {
// compileToFunction is absent in the runtime-only build
expect((vueI18n as Record<string, unknown>).compileToFunction).toBeUndefined();
});
});
A passing grep plus a passing build under your production CSP (no 'unsafe-eval') confirms all three breaking changes are handled.
When to escalate
This three-stage path assumes your messages are static JSON/YAML loaded at build time. If you load translations from a remote API at runtime, precompilation is impossible and you either keep the full bundler build with a hashed-source CSP exception, or compile messages server-side before they reach the client — a deeper change tracked through the broader Vue i18n Composition API Guide. Likewise, if you still see Not found 'key' warnings after cutover, the problem is fallback configuration rather than injection; chase it through vue-i18n fallback “not found key” warnings. Heavy custom mixins that wrapped $t with app-specific formatting may also need rewriting as composables before bridge mode can be removed.
FAQ
Can I leave the app in legacy: true permanently?
You can boot indefinitely in bridge mode, but it ships the compatibility layer and global injection, blocks tree-shaking of unused message helpers, and locks you out of useI18n scoping benefits. Treat it as a migration window, not an endpoint — flip legacy: false once the grep for this.$t comes back empty.
Why do I get unsafe-eval CSP errors only after upgrading?
v9’s default bundler build includes a message compiler that calls new Function() to turn message strings into render functions. Under a CSP without 'unsafe-eval' that throws an EvalError. Alias to vue-i18n.runtime.esm-bundler.js and precompile messages at build time so no compilation happens in the browser.
Why did locale switching stop being reactive after I ported the component?
useI18n() returns locale as a Ref. Assigning locale = 'es' replaces the ref and breaks reactivity; you must write locale.value = 'es'. This is the most common silent regression when porting this.$i18n.locale = 'es' from v8.
Related
- Vue i18n Composition API Guide — full
createI18nsetup, scoping, and router integration this migration plugs into. - vue-i18n fallback “not found key” warnings — diagnosing
Not found 'key'noise that often surfaces right after cutover. - React i18next Component Patterns — the equivalent hook-based translation model in React for teams running both stacks.
- Fallback Chain Configuration — how
fallbackLocaleresolves missing strings oncefallbackWarnis silenced.
Part of Vue i18n Composition API Guide.