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 so this.$t keeps working; stage 2 moves components to useI18n() incrementally; stage 3 flips legacy:false and aliases the runtime-only build to remove global injection and the in-browser compiler. Incremental migration keeps the app booting at every commit Stage 1 · Bridge legacy: true this.$t still works Stage 2 · Hooks useI18n() one PR per component Stage 3 · Cutover legacy: false runtime-only build 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.

Three breaking changes and their remedies The prototype injection removal is fixed by destructuring useI18n().t; locale becoming a Ref is fixed by assigning through locale.value; and the in-browser message compiler requiring eval is fixed by aliasing the runtime-only build and precompiling messages. One upgrade bundles three orthogonal breakages — each needs its own fix V9 BREAKING CHANGE REMEDY Prototype injection removed this.$t is not a function useI18n().t('key') destructure in <script setup> locale is now a Ref locale = 'es' // no reactivity locale.value = 'es' assign through .value Compiler needs eval EvalError: unsafe-eval vue-i18n.runtime.* alias build + precompile
The three failures are independent — a migration that fixes only the $t injection still ships CSP eval violations until the runtime build is aliased.

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:

<!-- BrokenWelcome.vue — works on v8, throws on v9 legacy:false -->
<template>
  <p>{{ $t('welcome.message') }}</p>
  <button @click="$i18n.locale = 'es'">ES</button>
</template>

<script>
export default {
  // No useI18n(), relies on prototype injection that no longer exists
  mounted() {
    console.log(this.$t('welcome.message')); // TypeError: this.$t is not a function
  },
};
</script>

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

Four migration stages from vue-i18n v8 to v9 Stage one installs version nine while keeping legacy mode, so nothing changes behaviourally. Stage two turns legacy mode off but enables global injection so existing templates keep resolving. Stage three converts components to useI18n one route at a time. Stage four removes global injection once no component depends on it. A migration that never breaks the build stage 1 Install v9 beside the old API legacy: true, nothing else changes stage 2 Flip legacy: false with globalInjection templates keep working through $t stage 3 Convert components to useI18n() one route at a time stage 4 Drop globalInjection the bridge is no longer load-bearing
Only stage three touches component code; the other three are configuration flips.

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.

<!-- FixedWelcome.vue -->
<script setup lang="ts">
import { useI18n } from 'vue-i18n';

// useScope: 'global' reuses the app-level instance; omit for component-local messages
const { t, locale } = useI18n({ useScope: 'global' });

const switchLocale = (target: string) => {
  locale.value = target; // .value is mandatory — `locale = target` breaks reactivity
};
</script>

<template>
  <p>{{ t('welcome.message') }}</p>
  <button @click="switchLocale('es')">ES</button>
</template>

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.