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$ton 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
$tcas the default. Pluralization is handled by$twith a count parameter; legacy$tcstays 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-Languagewith 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 ofv-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-textand 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.
