World map and language labels representing internationalization
8 min readVue 3 Migration

vue-i18n for Vue 3: Migrating Locales, Lazy Loading, and ICU Messages Without Breaking Production

Internationalized apps are easy to break silently: a missing key here, a plural rule wrong in German there. The Vue 3 line of vue-i18n is not a version bump in package.json—it is a chance to re-align your locale loading with route-based code splitting, fix legacy JSON shapes, and sync with Element Plus or other UI library locales. If you run SSR, locale resolution on server and client must match to avoid flash-of-wrong-language.

1. Lazy-load locales with routes

Ship a minimal core bundle, then import() locale files when users switch language or when a code-split page mounts. This pairs with bundle and LCP goals.

2. Plural, datetime, and ICU

Move complex messages to a consistent ICU-style format. Test with real translators’ inputs, not only English default strings.

3. Fallbacks and error reporting

Log missing keys in non-prod, cap noise in prod, and add CI checks so keys do not rot between branches.

4. The vue-i18n v8 → v9 breaking changes that matter

vue-i18n on Vue 3 is a rewrite, not a port. A handful of changes account for most migration friction:

  • Legacy mode vs Composition API mode. By default, vue-i18n v9 uses Composition API mode (legacy: false). If you want the old $t on every component, you must opt in.
  • Message format strictness. v9 enforces the message compiler. Strings that worked by accident (unbalanced braces, stray {) now throw at build time, which is good — but it surfaces all at once.
  • No more $tc as the default. Pluralization is handled by $t with a count parameter; legacy $tc stays available, but new code should use the unified API.
  • Locale messages are flattened. v9 expects message catalogs as plain objects; some v8 setups relied on functions that need to be re-expressed.
  • SSR API moved. The previous global instance pattern needs to be replaced with per-request instances created in your server entry — critical for SSR setups.

5. Lazy loading locales: the pattern that scales

Shipping every locale in the main bundle is the i18n equivalent of shipping every route — fine until you support more than two languages. Here is the lazy-loading pattern we use across enterprise apps:

// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'

export const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'ja', 'pt-BR'] as const
export type Locale = typeof SUPPORTED_LOCALES[number]

export const i18n = createI18n({
  legacy: false,
  locale: 'en',
  fallbackLocale: 'en',
  messages: { en },
})

const loadedLocales = new Set<Locale>(['en'])

export async function setLocale(locale: Locale) {
  if (!loadedLocales.has(locale)) {
    const messages = await import(
      /* webpackChunkName: "locale-[request]" */
      `./locales/${locale}.json`
    )
    i18n.global.setLocaleMessage(locale, messages.default)
    loadedLocales.add(locale)
  }
  i18n.global.locale.value = locale
  document.documentElement.lang = locale
}

Three things to notice: only English ships in the main bundle, locales are cached in a Set so a re-selection is instant, and the html lang attribute is updated for accessibility. On Vite, replace the dynamic import comment with import.meta.glob('./locales/*.json'); the rest stays identical.

Pair this with a router guard that calls setLocale(to.params.lang) before navigation resolves — see Vue Router 3 → 4.

6. Pluralization, dates, and ICU in practice

English plural rules are deceptively simple. Slavic languages, Arabic, and Welsh have multiple plural categories that English-only translators never anticipate. Use ICU MessageFormat for anything user-facing that involves a count or a date:

// en.json
{
  "cart_items": "{count, plural, =0 {No items} one {1 item} other {# items}}",
  "order_total": "Total: {amount, number, ::currency/EUR}"
}

// de.json
{
  "cart_items": "{count, plural, =0 {Keine Artikel} one {1 Artikel} other {# Artikel}}",
  "order_total": "Summe: {amount, number, ::currency/EUR}"
}

// In a component
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>
<template>
  <p>{{ t('cart_items', { count: items.length }) }}</p>
  <p>{{ t('order_total', { amount: 42.5 }) }}</p>
</template>

Test with realistic data: a German plural for 0, a Russian plural for 21, an Arabic dual. The bugs that ship to production are the rules English-speaking devs never look at.

7. SSR and hydration: avoiding the flash-of-wrong-language

If you render on the server, the locale used to render must be the locale used to hydrate. The classic bug: server reads Accept-Language as de, client falls back to en because the cookie isn’t read on first render — users see English flash for ~80ms before German appears.

A few rules that prevent this:

  • Resolve the locale in one place that both server and client agree on. Cookies + Accept-Language with explicit precedence.
  • Inline the resolved locale into the initial HTML (window.__LOCALE__) so the client never has to guess.
  • Pre-load the messages for the SSR-resolved locale before app.mount() runs.
  • Set <html lang> server-side; it is also a strong SEO signal.

If you’re on Nuxt, most of this is handled by @nuxtjs/i18n. If you’re moving from Nuxt 2, plan locale resolution as part of the broader Nuxt 2 to Nuxt 3 migration.

8. Coordinating with UI library locales

vue-i18n covers your strings. The UI library (Element Plus, Vuetify, Quasar) carries its own locale data for date pickers, validation messages, and pagination. They must be switched together; otherwise the date picker says “Mon Tue Wed” while the rest of the page is in Japanese.

Set up one setLocale() function that updates all three at once: vue-i18n, the UI library, and any third-party widgets (charts, rich-text editors). For Element Plus the pattern is in our Element UI → Element Plus migration; for Quasar, Quasar v1 to v2 covers it.

9. Common pitfalls and CI guardrails

  • Missing key as silent string. In production, vue-i18n returns the key when a translation is missing. Add a CI check that diffs locale files for missing keys against en.json.
  • Hardcoded English in templates. Easy to miss during reviews. A simple ESLint rule (vue-i18n/no-raw-text) catches them.
  • Translator-introduced HTML. Translators sometimes add stray tags. Render translations as text by default; use <i18n-t> for interpolated components instead of v-html.
  • Locale fallback chains. Configure them explicitly, e.g. 'pt-BR' → 'pt' → 'en'. Don’t rely on accidental ordering.
  • Bundle bloat from JSON. If you have hundreds of keys per locale, JSON modules can dwarf your code. Track this in your bundle-size budget.

10. Migration checklist for vue-i18n

  • Decide between legacy mode and Composition API mode. Pick one; document it.
  • Run the build with the new message compiler and triage every warning.
  • Convert pluralized strings to ICU MessageFormat.
  • Split locale catalogs into per-language files and lazy-load them.
  • Centralize setLocale() so vue-i18n, the UI library, and third-party widgets switch together.
  • Verify SSR and hydration use the same resolved locale.
  • Add ESLint no-raw-text and a CI key-diff check.
  • Test at least one non-English locale on the smoke pack — see Cypress component testing.

11. FAQ

Should we keep legacy mode or move to Composition API mode?

If most of the app is moving to <script setup> and Pinia, switch to Composition API mode in the same release. If you’re on a long compat-mode runway, legacy mode is a fine bridge.

Can we use a translation management platform?

Yes — Phrase, Lokalise, Crowdin all integrate cleanly with JSON catalogs. The migration is a good moment to introduce one if you don’t have it; otherwise translators will keep editing JSON in PRs.

What about right-to-left languages?

RTL is a CSS concern as much as an i18n one. Set dir="rtl" in setLocale() for Arabic/Hebrew/Persian, and audit Tailwind logical properties (ms-*, me-*) — much easier on a fresh Vue 3 codebase.

How long does this migration take?

For most apps, vue-i18n itself is a 1–2 week task. The catalog hygiene and translator workflow that comes with it is usually longer. Plan it in the same envelope as overall migration timelines.

Conclusion

vue-i18n on Vue 3 is an opportunity to fix years of ad hoc key naming and to load languages like any other code asset—if you schedule it, not as a one-night merge.

Related