Table of Contents
Vue i18n has matured significantly with the Composition API. If you're still following Options API patterns from tutorials written in 2020, you're leaving a lot on the table — especially around type safety, lazy loading, and CDN-delivered messages.
This is a practical guide to building multi-language Vue 3 applications that scale: from per-component translations to route-based locale switching to Nuxt 3 integration. We'll cover the patterns that hold up at enterprise scale, not just the ones that work for demos.
Setting Up vue-i18n with Composition API
Start with the Composition API mode. The Options API mode still exists for backwards compatibility, but it doesn't give you the composable patterns that make large codebases manageable.
npm install vue-i18n@9
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
export type MessageSchema = typeof import('./locales/en.json')
export const i18n = createI18n<[MessageSchema], 'en' | 'fr' | 'de'>({
legacy: false, // Composition API mode
locale: 'en',
fallbackLocale: 'en',
messages: {
en: {} // loaded lazily
}
})
Setting legacy: false is the critical step. It unlocks useI18n() and the full Composition API surface.
useI18n(): The Core Composable
Every component that needs translations uses useI18n(). The basic pattern:
// src/components/WelcomeHeader.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>
<template>
<h1>{{ t('welcome.heading') }}</h1>
<p>{{ t('welcome.subheading', { plan: 'Pro' }) }}</p>
</template>
The t() function is reactive. When locale changes, all t() calls re-evaluate automatically. This means locale switching just works without any manual re-rendering.
Global vs. Local Scope
This is where most teams get it wrong at scale. vue-i18n supports two scopes, and mixing them carelessly creates maintenance problems.
Global scope — messages defined at the i18n instance level, accessible everywhere:
// src/i18n/locales/en.json
{
"nav.home": "Home",
"nav.pricing": "Pricing",
"common.save": "Save",
"common.cancel": "Cancel"
}
Local scope — messages scoped to a specific component, defined in the component itself:
// src/components/BillingForm.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t } = useI18n({
messages: {
en: {
title: 'Billing Information',
cardNumber: 'Card number',
expiryDate: 'Expiry date',
errors: {
invalidCard: 'Please check your card number'
}
},
fr: {
title: 'Informations de facturation',
cardNumber: 'Numéro de carte',
expiryDate: "Date d'expiration",
errors: {
invalidCard: 'Veuillez vérifier votre numéro de carte'
}
}
}
})
</script>
The rule of thumb: navigation, common actions, and error messages that appear across the app go in global scope. Feature-specific UI copy goes in local scope, co-located with the component.
Local scope messages don't pollute the global namespace and make it obvious which translations belong to which feature. When you delete a component, you delete its translations with it.
TypeScript Type Safety
Without type safety, i18n becomes a runtime error problem. You won't know a translation key is missing until a user sees an empty string in production.
vue-i18n supports full TypeScript integration. The setup requires a type declaration:
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import enMessages from './locales/en.json'
type MessageSchema = typeof enMessages
declare module 'vue-i18n' {
export interface DefineLocaleMessage extends MessageSchema {}
}
export const i18n = createI18n<false, MessageSchema>({
legacy: false,
locale: 'en',
messages: {
en: enMessages
}
})
Now t() is fully typed. Pass a key that doesn't exist, and TypeScript catches it at compile time:
const { t } = useI18n()
t('nav.home') // OK
t('nav.homee') // TypeScript error: Argument of type '"nav.homee"' is not assignable
t('welcome.heading') // OK — if it exists in the schema
At scale, this is the difference between i18n being a maintenance burden and it being invisible. Your IDE autocompletes keys. Typos fail at build time, not in production.
Lazy Loading Locales
Loading all locales upfront is fine for small apps. For enterprise apps with 20+ languages and thousands of keys, it's not. The solution is lazy loading: load the active locale on demand.
// src/i18n/loader.ts
import { i18n } from './index'
const loadedLocales = new Set<string>(['en']) // en loaded at init
export async function loadLocale(locale: string): Promise<void> {
if (loadedLocales.has(locale)) return
const messages = await import(`./locales/${locale}.json`)
i18n.global.setLocaleMessage(locale, messages.default)
loadedLocales.add(locale)
}
export async function setLocale(locale: string): Promise<void> {
await loadLocale(locale)
i18n.global.locale.value = locale as any
document.documentElement.setAttribute('lang', locale)
}
Use this in a locale switcher component:
// src/components/LocaleSwitcher.vue
<script setup lang="ts">
import { setLocale } from '@/i18n/loader'
const locales = [
{ code: 'en', label: 'English' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
]
async function handleChange(code: string) {
await setLocale(code)
}
</script>
<template>
<select @change="e => handleChange((e.target as HTMLSelectElement).value)">
<option v-for="l in locales" :key="l.code" :value="l.code">
{{ l.label }}
</option>
</select>
</template>
Each locale bundle is a separate chunk. Users only download the language they use.
Route-Based Locale Switching
For SEO-critical apps, locale should live in the URL — /en/pricing, /fr/tarifs. This approach gives each locale its own indexable URL and lets search engines understand the language structure of your site.
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { setLocale } from '@/i18n/loader'
const SUPPORTED_LOCALES = ['en', 'fr', 'de']
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/:locale(en|fr|de)',
component: () => import('@/layouts/LocaleLayout.vue'),
children: [
{ path: '', component: () => import('@/pages/Home.vue') },
{ path: 'pricing', component: () => import('@/pages/Pricing.vue') },
{ path: 'features', component: () => import('@/pages/Features.vue') },
]
},
{
path: '/:pathMatch(.*)*',
redirect: to => `/en/${to.params.pathMatch}`
}
]
})
router.beforeEach(async (to) => {
const locale = to.params.locale as string
if (SUPPORTED_LOCALES.includes(locale)) {
await setLocale(locale)
}
})
export default router
The locale layout component handles <link rel="alternate" hreflang> tags for SEO:
// src/layouts/LocaleLayout.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useHead } from '@vueuse/head'
const route = useRoute()
const locale = route.params.locale as string
const LOCALES = ['en', 'fr', 'de']
useHead({
htmlAttrs: { lang: locale },
link: LOCALES.map(l => ({
rel: 'alternate',
hreflang: l,
href: `https://example.com/${l}${route.path.replace(`/${locale}`, '')}`
}))
})
</script>
<template>
<RouterView />
</template>
Pluralization and Formatting
Pluralization is where naive i18n implementations break. English has two plural forms. Arabic has six. Russian uses different forms depending on the last digit of the number. vue-i18n handles this correctly if you use it correctly.
Pluralization:
// en.json
{
"items": "no items | one item | {count} items"
}
// ru.json — Russian plural rules
{
"items": "нет элементов | {count} элемент | {count} элемента | {count} элементов"
}
const { t, n, d } = useI18n()
// Pluralization
t('items', 0) // "no items"
t('items', 1) // "one item"
t('items', 42) // "42 items"
// Number formatting with named formats
n(1234567.89, 'currency') // "$1,234,567.89" in en, "1 234 567,89 €" in fr
Define named formats at the i18n instance level:
export const i18n = createI18n({
legacy: false,
locale: 'en',
numberFormats: {
en: {
currency: { style: 'currency', currency: 'USD', notation: 'standard' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
},
fr: {
currency: { style: 'currency', currency: 'EUR', notation: 'standard' },
decimal: { style: 'decimal', minimumFractionDigits: 2 }
}
},
datetimeFormats: {
en: {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
},
fr: {
short: { year: 'numeric', month: 'short', day: 'numeric' },
long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
}
}
})
Now n() and d() format correctly for each locale without any per-component logic.
CDN-Delivered Messages vs. Bundled JSON
Bundling JSON locale files into your app has a real trade-off: every translation update requires a redeploy. For teams that update translations frequently — running A/B tests on copy, correcting errors, localizing for new markets — this is a meaningful constraint.
The alternative is CDN-delivered messages. Your translation files live on a CDN, and your app fetches them at runtime. This works with any Vue setup — Nuxt 3, Vite SPA, or custom server rendering.
// src/i18n/cdn-loader.ts
const CDN_BASE = 'https://cdn.example.com/i18n'
export async function loadLocaleFromCDN(locale: string): Promise<Record<string, string>> {
const res = await fetch(`${CDN_BASE}/${locale}.json`, {
headers: { 'Cache-Control': 'max-age=3600' }
})
if (!res.ok) throw new Error(`Failed to load locale: ${locale}`)
return res.json()
}
The trade-off is a network request on locale load. With a CDN with proper caching, this is typically a cache hit after the first load. The benefit is that your translation team can ship copy updates instantly, without touching the code deployment pipeline.
At Better i18n, CDN delivery is built into the platform. Translations go through a review workflow, get published, and are instantly available globally through the CDN — no redeploy, no PR for a typo fix. The features page covers how the delivery pipeline works, and if you're evaluating this for a Vue project specifically, the for developers page has SDK integration examples.
Nuxt 3 Integration
Nuxt 3 has first-class i18n support through @nuxtjs/i18n, which wraps vue-i18n and adds server-side rendering, route generation, and SEO utilities.
npm install @nuxtjs/i18n
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
locales: [
{ code: 'en', iso: 'en-US', file: 'en.json' },
{ code: 'fr', iso: 'fr-FR', file: 'fr.json' },
{ code: 'de', iso: 'de-DE', file: 'de.json' }
],
defaultLocale: 'en',
langDir: 'i18n/locales/',
lazy: true,
strategy: 'prefix_except_default',
detectBrowserLanguage: {
useCookie: true,
cookieKey: 'i18n_redirected',
redirectOn: 'root'
}
}
})
With strategy: 'prefix_except_default', English routes have no prefix (/pricing) while other locales do (/fr/tarifs). Nuxt handles the route generation automatically.
In components, useI18n() works identically to a non-Nuxt app:
// pages/pricing.vue
<script setup lang="ts">
const { t } = useI18n()
useSeoMeta({
title: t('pricing.meta.title'),
description: t('pricing.meta.description')
})
</script>
The SSR integration means translated content is in the initial HTML — no flash of untranslated content, and search engines see the localized version.
Scaling the Developer Workflow
As your translation surface grows, the bottleneck shifts from technical setup to workflow. A few patterns that hold up at scale:
Keep translation keys semantic, not positional. nav.pricing ages better than link3. When the nav gets redesigned, link3 becomes meaningless. nav.pricing is still correct.
Treat missing translations as build errors. Configure your build to warn or fail when a key exists in the default locale but is missing in a target locale. Catch it before users do.
Automate the translation extraction. Running vue-i18n-extract or similar tools as part of CI ensures that every new string gets flagged for translation before it ships.
Separate the translation review from the code review. Translation quality is a different skill than code quality. If translators need to open a PR to fix a typo, you've created unnecessary friction. Use a translation platform where language review happens in the language, not in Git.
The technical foundation described in this post handles the engineering side. The workflow side — getting translations reviewed, managing translator access, keeping glossaries consistent across languages — is where dedicated tooling pays off disproportionately.