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.

vue-i18n v8 to v9 migration stages Stage 1 boots v9 in legacy:true bridge mode keeping this.$t working; stage 2 moves components to useI18n() incrementally; stage 3 flips legacy:false to remove global injection. Stage 1 · Bridge createI18n(legacy:true) this.$t still works Stage 2 · Hooks useI18n() per component migrate incrementally Stage 3 · Cutover legacy:false no global injection Incremental migration keeps the app booting at every commit v8 $t → bridge → useI18n() → v9 t()
The bridge stage is what makes the upgrade reviewable: each component PR is small and the build never goes red.

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:




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.




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.

Part of Vue i18n Composition API Guide.