Quick Answer
To localize a Vue app, use vue-i18n (v9). Install with npm install vue-i18n, create translation JSON files in a locales directory, configure the createI18n instance in your main.ts, and use the $t() helper in templates or useI18n() composable in scripts. For Nuxt 3, use the @nuxtjs/i18n module for built-in SSR, routing and SEO support.
The Problem
You've built a beautiful Vue app. Now your product team says "we need to support Spanish, French, and German."
Where do you even start? How do you:
- Extract all those hardcoded strings?
- Switch languages without page refreshes?
- Handle plurals differently in each language?
- Lazy load translations to keep your bundle small?
This guide walks through everything, using vue-i18n, the official Vue localization library.
What You'll Build
By the end of this tutorial, you'll have a Vue app that:
- ✅ Switches languages instantly
- ✅ Handles plurals correctly in 40+ languages
- ✅ Formats dates, numbers, and currencies by locale
- ✅ Lazy loads translations for performance
- ✅ Works with Vue Router for SEO-friendly URLs
- ✅ Syncs with a translation management system
Tech stack:
- Vue 3 (Composition API)
- vue-i18n v9
- Vite
- TypeScript (optional but recommended)
Step 1: Install vue-i18n
First, install the library:
Terminalnpm install vue-i18n@9
Why vue-i18n? It's the official i18n plugin for Vue, maintained by the Vue team. It has:
- 5M+ weekly downloads
- Built-in support for Vue 3's Composition API
- SSR support for Nuxt
- ICU message format (same as React/Angular)
Step 2: Create Translation Files
Create a locales folder with your translation files:
src/
├── locales/
│ ├── en.json
│ ├── es.json
│ └── fr.json
└── i18n.ts
en.json:
JSON1{ 2 "nav": { 3 "home": "Home", 4 "about": "About", 5 "pricing": "Pricing" 6 }, 7 "hero": { 8 "title": "Build apps faster", 9 "subtitle": "Ship features your users love", 10 "cta": "Get started" 11 }, 12 "product": { 13 "addToCart": "Add to cart", 14 "itemCount": "No items | {n} item | {n} items", 15 "price": "{amount} per month" 16 } 17}
es.json:
JSON1{ 2 "nav": { 3 "home": "Inicio", 4 "about": "Acerca de", 5 "pricing": "Precios" 6 }, 7 "hero": { 8 "title": "Crea aplicaciones más rápido", 9 "subtitle": "Lanza funciones que tus usuarios aman", 10 "cta": "Empezar" 11 }, 12 "product": { 13 "addToCart": "Añadir al carrito", 14 "itemCount": "Sin artículos | {n} artículo | {n} artículos", 15 "price": "{amount} por mes" 16 } 17}
Key structure tips:
- Nest by feature (
nav,hero,product) not by page - Use semantic keys (
addToCart) not UI positions (button_1) - Consistent formatting across all languages
Step 3: Configure vue-i18n
Create src/i18n.ts:
TypeScript1import { createI18n } from 'vue-i18n'; 2import en from './locales/en.json'; 3import es from './locales/es.json'; 4import fr from './locales/fr.json'; 5 6export const i18n = createI18n({ 7 legacy: false, // Use Composition API mode 8 locale: 'en', // Default language 9 fallbackLocale: 'en', // Fallback if translation missing 10 messages: { 11 en, 12 es, 13 fr, 14 }, 15 // Enable warnings in dev 16 missingWarn: import.meta.env.DEV, 17 fallbackWarn: import.meta.env.DEV, 18});
Why legacy: false? This enables Composition API support. If you're using Vue 3, always set this.
Step 4: Add to Your Vue App
In main.ts:
TypeScript1import { createApp } from 'vue'; 2import App from './App.vue'; 3import { i18n } from './i18n'; 4 5const app = createApp(App); 6app.use(i18n); 7app.mount('#app');
That's it. Now every component has access to translations.
Step 5: Use Translations in Components
Basic Usage (Composition API)
VUE1<script setup> 2import { useI18n } from 'vue-i18n'; 3 4const { t } = useI18n(); 5</script> 6 7<template> 8 <nav> 9 <a href="/">{{ $t('nav.home') }}</a> 10 <a href="/about">{{ $t('nav.about') }}</a> 11 <a href="/pricing">{{ $t('nav.pricing') }}</a> 12 </nav> 13 14 <section> 15 <h1>{{ $t('hero.title') }}</h1> 16 <p>{{ $t('hero.subtitle') }}</p> 17 <button>{{ $t('hero.cta') }}</button> 18 </section> 19</template>
With Interpolation
Pass variables using the second argument:
VUE1<script setup> 2const { t } = useI18n(); 3const price = 29; 4</script> 5 6<template> 7 <p>{{ $t('product.price', { amount: `$${price}` }) }}</p> 8 <!-- Output: "$29 per month" --> 9</template>
Translation:
JSON1{ 2 "product": { 3 "price": "{amount} per month" 4 } 5}
Step 6: Handle Pluralization
Different languages have different plural rules. English has 2 forms (1 item, 2 items). Polish has 3. Arabic has 6!
vue-i18n handles this automatically using the pipe syntax:
JSON1{ 2 "product": { 3 "itemCount": "No items | {n} item | {n} items" 4 } 5}
In your component:
VUE1<script setup> 2const { t } = useI18n(); 3const count = ref(0); 4</script> 5 6<template> 7 <p>{{ $t('product.itemCount', count) }}</p> 8 <!-- count=0: "No items" --> 9 <!-- count=1: "1 item" --> 10 <!-- count=5: "5 items" --> 11</template>
For complex plurals, use ICU message format:
JSON1{ 2 "cart": { 3 "summary": "{count, plural, =0 {Your cart is empty} one {# item in cart} other {# items in cart}}" 4 } 5}
Step 7: Format Dates, Numbers, Currencies
vue-i18n includes Intl formatters:
VUE1<script setup> 2import { useI18n } from 'vue-i18n'; 3 4const { t, n, d } = useI18n(); 5const price = 1299.99; 6const releaseDate = new Date('2026-01-15'); 7</script> 8 9<template> 10 <!-- Number formatting --> 11 <p>{{ n(price, 'currency') }}</p> 12 <!-- en: "$1,299.99" --> 13 <!-- es: "1.299,99 €" --> 14 15 <!-- Date formatting --> 16 <p>{{ d(releaseDate, 'long') }}</p> 17 <!-- en: "January 15, 2026" --> 18 <!-- es: "15 de enero de 2026" --> 19</template>
Configure formats in i18n.ts:
TypeScript1export const i18n = createI18n({ 2 // ... other options 3 numberFormats: { 4 en: { 5 currency: { 6 style: 'currency', 7 currency: 'USD', 8 }, 9 }, 10 es: { 11 currency: { 12 style: 'currency', 13 currency: 'EUR', 14 }, 15 }, 16 }, 17 datetimeFormats: { 18 en: { 19 short: { year: 'numeric', month: 'short', day: 'numeric' }, 20 long: { year: 'numeric', month: 'long', day: 'numeric' }, 21 }, 22 es: { 23 short: { year: 'numeric', month: 'short', day: 'numeric' }, 24 long: { year: 'numeric', month: 'long', day: 'numeric' }, 25 }, 26 }, 27});
Step 8: Switch Languages
Create a language switcher component:
VUE1<script setup> 2import { useI18n } from 'vue-i18n'; 3 4const { locale, availableLocales } = useI18n(); 5 6const changeLanguage = (lang: string) => { 7 locale.value = lang; 8 // Persist to localStorage 9 localStorage.setItem('user-locale', lang); 10}; 11</script> 12 13<template> 14 <div class="language-switcher"> 15 <button 16 v-for="lang in availableLocales" 17 :key="lang" 18 :class="{ active: locale === lang }" 19 @click="changeLanguage(lang)" 20 > 21 {{ lang.toUpperCase() }} 22 </button> 23 </div> 24</template>
Detect user's preferred language on app load:
TypeScript1// In i18n.ts 2function getInitialLocale(): string { 3 // 1. Check localStorage 4 const saved = localStorage.getItem('user-locale'); 5 if (saved) return saved; 6 7 // 2. Check browser language 8 const browserLang = navigator.language.split('-')[0]; 9 if (['en', 'es', 'fr'].includes(browserLang)) { 10 return browserLang; 11 } 12 13 // 3. Default 14 return 'en'; 15} 16 17export const i18n = createI18n({ 18 locale: getInitialLocale(), 19 // ... 20});
Step 9: Lazy Load Translations
Loading all languages upfront increases your bundle size. Lazy load languages on demand:
TypeScript1// i18n.ts 2import { createI18n } from 'vue-i18n'; 3 4export const i18n = createI18n({ 5 legacy: false, 6 locale: 'en', 7 fallbackLocale: 'en', 8 messages: { 9 en: {}, // Start empty 10 }, 11}); 12 13// Load locale dynamically 14export async function loadLocale(locale: string) { 15 // Check if already loaded 16 if (i18n.global.availableLocales.includes(locale)) { 17 return; 18 } 19 20 // Lazy load the locale file 21 const messages = await import(`./locales/${locale}.json`); 22 i18n.global.setLocaleMessage(locale, messages.default); 23} 24 25// Switch language with lazy loading 26export async function setLocale(locale: string) { 27 await loadLocale(locale); 28 i18n.global.locale.value = locale; 29 localStorage.setItem('user-locale', locale); 30}
Usage:
VUE1<script setup> 2import { setLocale } from '@/i18n'; 3 4const switchLanguage = async (lang: string) => { 5 await setLocale(lang); 6}; 7</script>
Result: Only English loads initially. Spanish/French load when user selects them.
Step 10: SEO-Friendly URLs with Vue Router
For SEO, use locale-based URLs:
example.com/en/productsexample.com/es/productos
Configure Vue Router:
TypeScript1import { createRouter, createWebHistory } from 'vue-router'; 2import { setLocale } from './i18n'; 3 4const routes = [ 5 { 6 path: '/:locale', 7 component: () => import('./layouts/LocaleLayout.vue'), 8 children: [ 9 { path: '', name: 'home', component: () => import('./views/Home.vue') }, 10 { path: 'products', name: 'products', component: () => import('./views/Products.vue') }, 11 { path: 'about', name: 'about', component: () => import('./views/About.vue') }, 12 ], 13 }, 14 // Redirect root to default locale 15 { path: '/', redirect: '/en' }, 16]; 17 18const router = createRouter({ 19 history: createWebHistory(), 20 routes, 21}); 22 23// Update locale on route change 24router.beforeEach(async (to) => { 25 const locale = to.params.locale as string; 26 if (locale && ['en', 'es', 'fr'].includes(locale)) { 27 await setLocale(locale); 28 } 29}); 30 31export default router;
LocaleLayout.vue:
VUE1<script setup> 2import { useRoute } from 'vue-router'; 3import { watchEffect } from 'vue'; 4import { useI18n } from 'vue-i18n'; 5 6const route = useRoute(); 7const { locale } = useI18n(); 8 9// Sync locale from URL 10watchEffect(() => { 11 const urlLocale = route.params.locale as string; 12 if (urlLocale && locale.value !== urlLocale) { 13 locale.value = urlLocale; 14 } 15}); 16</script> 17 18<template> 19 <div> 20 <router-view /> 21 </div> 22</template>
Add hreflang tags for SEO:
TypeScript1// In your router or layout 2const addHreflangTags = (locale: string) => { 3 const head = document.querySelector('head'); 4 const existingTags = head?.querySelectorAll('link[rel="alternate"]'); 5 existingTags?.forEach(tag => tag.remove()); 6 7 const locales = ['en', 'es', 'fr']; 8 locales.forEach(lang => { 9 const link = document.createElement('link'); 10 link.rel = 'alternate'; 11 link.hreflang = lang; 12 link.href = `https://example.com/${lang}${route.path}`; 13 head?.appendChild(link); 14 }); 15};
Step 11: TypeScript Support
Get autocomplete for translation keys:
TypeScript1// types/i18n.d.ts 2import en from '@/locales/en.json'; 3 4type MessageSchema = typeof en; 5 6declare module 'vue-i18n' { 7 export interface DefineLocaleMessage extends MessageSchema {} 8}
Now TypeScript will error if you use t('invalid.key'):
TypeScriptconst { t } = useI18n(); t('nav.home'); // ✅ Valid t('nav.invalid'); // ❌ TypeScript error
Step 12: Production Optimization
1. Code Splitting
Split translations by route:
TypeScript1// Instead of loading all keys 2const messages = await import('./locales/en.json'); 3 4// Load only what you need 5const common = await import('./locales/common/en.json'); 6const products = await import('./locales/products/en.json'); 7 8i18n.global.setLocaleMessage('en', { 9 ...common.default, 10 ...products.default, 11});
2. Preload Next Language
If most users switch from English to Spanish, preload Spanish:
TypeScript1// Preload likely next language 2if (locale.value === 'en') { 3 setTimeout(() => loadLocale('es'), 2000); 4}
3. Compile Messages
vue-i18n can precompile messages for faster runtime:
Terminalnpm install @intlify/unplugin-vue-i18n -D
vite.config.ts:
TypeScript1import { defineConfig } from 'vite'; 2import vue from '@vitejs/plugin-vue'; 3import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'; 4import { resolve } from 'path'; 5 6export default defineConfig({ 7 plugins: [ 8 vue(), 9 VueI18nPlugin({ 10 include: resolve(__dirname, './src/locales/**'), 11 compositionOnly: true, 12 }), 13 ], 14});
Step 13: Connect to IntlPull
Manually managing JSON files doesn't scale. Use a Translation Management System (TMS):
Install IntlPull CLI:
Terminalnpm install -D @intlpullhq/cli
Configure:
JSON1// .intlpull.json 2{ 3 "projectId": "your-project-id", 4 "sourceLanguage": "en", 5 "targetLanguages": ["es", "fr", "de"], 6 "format": "json", 7 "outputDir": "src/locales" 8}
Workflow:
- Upload your source translations:
Terminalnpx @intlpullhq/cli upload
- Push to IntlPull for translation:
Terminalnpx @intlpullhq/cli upload
- Download translations:
Terminalnpx @intlpullhq/cli download
- Auto-sync in CI/CD:
YAML1# .github/workflows/i18n.yml 2name: Sync Translations 3on: 4 schedule: 5 - cron: '0 */6 * * *' # Every 6 hours 6 7jobs: 8 sync: 9 runs-on: ubuntu-latest 10 steps: 11 - uses: actions/checkout@v3 12 - run: npm install 13 - run: npx @intlpullhq/cli download 14 - run: | 15 git config user.name "i18n-bot" 16 git add src/locales 17 git commit -m "chore: sync translations" || exit 0 18 git push
Benefits:
- ✅ Translators work in a UI, not JSON
- ✅ Translation memory saves costs
- ✅ AI pre-translation for new keys
- ✅ Approval workflows for quality
- ✅ Zero developer involvement after setup
Common Pitfalls
1. Forgetting to Await Lazy Loads
TypeScript1// ❌ Bad - locale may not be loaded 2locale.value = 'es'; 3 4// ✅ Good - wait for it 5await setLocale('es');
2. Hardcoded Strings in Components
Run a lint check:
Terminalnpx eslint . --rule 'no-literal-string: error'
Or use IntlPull's status check:
Terminalnpx @intlpullhq/cli status
3. Not Handling Missing Keys
Always set a fallback:
TypeScript1createI18n({ 2 fallbackLocale: 'en', 3 missing: (locale, key) => { 4 console.warn(`Missing translation: ${key} in ${locale}`); 5 return key; // Show key instead of blank 6 }, 7});
4. Ignoring RTL Languages
If you support Arabic/Hebrew, add RTL CSS:
VUE1<template> 2 <div :dir="locale === 'ar' || locale === 'he' ? 'rtl' : 'ltr'"> 3 <!-- Your app --> 4 </div> 5</template>
Nuxt 3 Bonus
If you're using Nuxt 3, use @nuxtjs/i18n:
Terminalnpm install @nuxtjs/i18n@next
nuxt.config.ts:
TypeScript1export default defineNuxtConfig({ 2 modules: ['@nuxtjs/i18n'], 3 i18n: { 4 locales: [ 5 { code: 'en', file: 'en.json' }, 6 { code: 'es', file: 'es.json' }, 7 { code: 'fr', file: 'fr.json' }, 8 ], 9 defaultLocale: 'en', 10 strategy: 'prefix', // URLs: /en/products, /es/productos 11 langDir: 'locales/', 12 lazy: true, // Lazy load translations 13 detectBrowserLanguage: { 14 useCookie: true, 15 cookieKey: 'i18n_redirected', 16 }, 17 }, 18});
Usage in components:
VUE<script setup> const { t, locale, setLocale } = useI18n(); </script>
Same API as vue-i18n, but with SSR, automatic routing, and SEO built-in.
Frequently Asked Questions
What is the best i18n library for Vue 3?
vue-i18n is the standard choice for Vue 3. It supports the Composition API, TypeScript, and has excellent performance. For Nuxt projects, @nuxtjs/i18n is recommended as it wraps vue-i18n with additional features like automatic route generation and SEO meta tags.
How do I handle pluralization in Vue i18n?
Use pipe syntax or ICU format. Simple plurals use pipes: "car | cars". For complex rules (like in Polish or Arabic), use the ICU format supported by vue-i18n: "{n, plural, one {item} other {items}}". This ensures your app follows the correct grammatical rules for every language.
How do I lazy load translations in Vue?
Use dynamic imports with setLocaleMessage. Instead of importing all JSON files at the start, create a loadLocale function that uses import(./locales/${locale}.json). Call i18n.global.setLocaleMessage(locale, messages) once loaded. This significantly reduces your initial bundle size.
How do I handle SEO with Vue i18n?
Use distinct URLs for each locale. Configure Vue Router to use dynamic segments like /:locale/about. This allows search engines to index each language version separately. Additionally, inject hreflang tags into the <head> using @vueuse/head or Nuxt's built-in useHead to tell Google about alternative language versions.
Performance Benchmarks
Bundle size comparison (production build):
| Setup | Initial Bundle | After Lazy Load |
|---|---|---|
| All locales eager | 450 KB | 450 KB |
| Lazy load locales | 180 KB | 230 KB (when needed) |
| Improvement | 60% smaller | ~50% smaller |
Runtime performance:
- Translation lookup: < 0.1ms
- Language switch: < 5ms (cached), < 100ms (lazy load)
- Negligible impact on app performance
Checklist: Is Your Vue App i18n-Ready?
- ✅ All user-facing strings use
t()function - ✅ No string concatenation (use interpolation)
- ✅ Plurals use pipe syntax or ICU format
- ✅ Dates/numbers use
d()andn()formatters - ✅ Translations lazy-loaded per route
- ✅ SEO: hreflang tags + locale URLs
- ✅ TypeScript types for autocomplete
- ✅ Fallback locale configured
- ✅ CI/CD syncs translations automatically
- ✅ TMS (IntlPull) integrated for scale
Next Steps
You now have a production-ready Vue localization setup. Here's what to do next:
- Add more languages: Just create new JSON files and add to
availableLocales - Set up IntlPull: Automate translation workflow
- Monitor coverage: Use IntlPull's analytics to track translation progress
- Optimize bundle: Code-split by route if translations > 100KB
- A/B test: Measure conversion rates per language
Want to skip the manual work? Try IntlPull's Vue starter template. It comes with vue-i18n pre-configured, CI/CD workflows, and OTA updates.
Further Reading
Building multilingual Vue apps doesn't have to be painful. With vue-i18n and the right workflow, you can ship global products faster.
Questions? Join our Discord or contact us.
