Vue.js i18n: The complete guide to localizing Vue and Nuxt apps

Kinga Pomykała
Kinga Pomykała
Last updated: March 25, 202634 min read
Vue.js i18n: The complete guide to localizing Vue and Nuxt apps

Vue.js has a mature, first-class i18n ecosystem. Between vue-i18n for Vue 3, @nuxtjs/i18n for Nuxt, and typesafe-i18n for teams that want compile-time key safety, you have solid options for every project type and every team size.

But "solid options" doesn't mean effortless. Locale detection, translation file architecture, pluralization across languages with complex rules, lazy loading, and keeping translations in sync across releases all require deliberate decisions.

This guide walks through everything you need to localize a Vue 3 or Nuxt application properly: from initial setup and translation key organization through RTL support, CI/CD automation, and connecting your project to SimpleLocalize for ongoing translation management.

If you're new to internationalization concepts, start with our complete technical guide to i18n and software localization before diving in here.

The Vue i18n ecosystem

Before writing code, it helps to understand what you're working with and which tool fits which situation. Here's a quick comparison to help you choose your path:

Featurevue-i18n v11@nuxtjs/i18ntypesafe-i18n
FrameworkVue 3 (Vite/Webpack)Nuxt 3 onlyVue 3 / any framework
API styleComposition + OptionsComposition (auto-injected)Fully typed functions
Translation formatJSON / YAML / <i18n> blocksJSON / YAML (lazy-loaded)TypeScript .ts files
Type safetyOptional (via .d.ts declaration)Optional (via .d.ts declaration)Built-in, compile-time
PluralizationPipe syntax + ICUPipe syntax + ICUCustom plural rules
Locale routingManualAutomatic (/en/, /es/)Manual
SEO (hreflang)ManualAutomaticManual
Lazy loadingManual (dynamic imports)Built-in (lazy: true)Built-in (code splitting)
Best forMost Vue 3 + Vite appsAll Nuxt 3 appsTypeScript-heavy teams
  • vue-i18n v9+ is the standard i18n library for Vue 3. It supports Composition API, Options API, pluralization, datetime and number formatting, fallback chains, and, with the Vite plugin, pre-compiled translation files and <i18n> blocks in Single File Components.

  • @nuxtjs/i18n is a Nuxt module built on top of vue-i18n. It adds automatic locale-prefixed routing (/en/, /es/), SEO hreflang tags, server-side locale detection, lazy loading of translation files, and domain-based routing strategies. If you're on Nuxt, this is the right choice rather than wiring up vue-i18n manually.

  • typesafe-i18n is a TypeScript-first alternative that stores translations as .ts files and generates full type definitions. Instead of t('some.key') returning string, you call LL.some.key() with fully typed parameters. It catches missing translations and wrong argument types at compile time, not at runtime in production.

  • i18next-vue brings the battle-tested i18next ecosystem (used widely in React via react-i18next) to Vue. A good choice if your team already uses i18next on other parts of the stack. See the i18next-vue integration docs for setup details.

  • vue-intl wraps FormatJS for Vue, providing ICU message format support natively. Good for teams that need robust plural rules from day one across many target languages. See the vue-intl integration docs.

The decision tree for most projects is simple: Nuxt → @nuxtjs/i18n. Vue 3 with Vite and TypeScript team → consider typesafe-i18n or vue-i18n. Vue 3 with existing i18next investment → i18next-vue.

Setting up vue-i18n in Vue 3

Installation

npm install vue-i18n

v11 of vue-i18n is the current stable release. v9 and v10 have reached EOL.

Optionally add the Vite plugin for pre-compiled messages and <i18n> SFC block support:

npm install -D @intlify/unplugin-vue-i18n

Vite config

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

export default defineConfig({
  plugins: [
    vue(),
    VueI18nPlugin({
      include: resolve(dirname(fileURLToPath(import.meta.url)), './src/i18n/locales/**'),
    }),
  ],
})

Translation files

Create translation files under src/i18n/locales/. Each language gets its own JSON file:

src/
  i18n/
    locales/
      en.json
      es.json
      fr.json
      pl.json

A typical en.json:

{
  "site": {
    "name": "Pillow Hotel",
    "slogan": "Your comfort, our priority"
  },
  "navigation": {
    "home": "Home",
    "rooms": "Rooms",
    "dining": "Dining",
    "spa": "Spa & Wellness",
    "booking": "Book Now",
    "contact": "Contact Us"
  },
  "homepage": {
    "title": "Welcome to Pillow Hotel",
    "description": "Your comfort, our priority"
  },
  "booking": {
    "price": "Price per night: {price}",
    "checkIn": "Check-in: {date}",
    "roomsBooked": "You have {count} room booked | You have {count} rooms booked"
  },
  "guest": {
    "greeting": "Welcome back, {name}!"
  },
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "required": "This field is required"
  }
}

The corresponding es.json:

{
  "site": {
    "name": "Hotel Almohada",
    "slogan": "Tu comodidad, nuestra prioridad"
  },
  "navigation": {
    "home": "Inicio",
    "rooms": "Habitaciones",
    "dining": "Restaurante",
    "spa": "Spa y bienestar",
    "booking": "Reservar ahora",
    "contact": "Contáctenos"
  },
  "homepage": {
    "title": "Bienvenido al Hotel Almohada",
    "description": "Tu comodidad, nuestra prioridad"
  },
  "booking": {
    "price": "Precio por noche: {price}",
    "checkIn": "Entrada: {date}",
    "roomsBooked": "Tiene {count} habitación reservada | Tiene {count} habitaciones reservadas"
  },
  "guest": {
    "greeting": "¡Bienvenido de nuevo, {name}!"
  },
  "common": {
    "save": "Guardar",
    "cancel": "Cancelar",
    "required": "Este campo es obligatorio"
  }
}

i18n plugin setup

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

export type MessageSchema = typeof en

export const i18n = createI18n({
  legacy: false,      // Composition API mode — always use this for Vue 3
  locale: 'en',
  fallbackLocale: 'en',
  globalInjection: true, // Makes $t available in templates globally
  messages: { en, es },
})

Warning: The legacy: false flag is essential. It switches vue-i18n from its Options API compatibility mode (Vue 2-style) to proper Composition API mode, unlocking useI18n() in <script setup> and accurate TypeScript types. Omitting it is the single most common setup mistake: everything will appear to work, but useI18n() will throw errors and TypeScript types won't resolve correctly.

Register in main.ts

// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n'

createApp(App).use(i18n).mount('#app')

Pro-Tip: If you need to use the i18n instance outside of components (for example, in Vue Router navigation guards to translate page titles or in API interceptors to set Accept-Language headers), make sure to export it from src/i18n/index.ts (as shown above) and import it directly: import { i18n } from '@/i18n'. Then use i18n.global.t('key') instead of useI18n(), which only works inside setup(). However, avoid injecting the i18n instance into Pinia stores, stores should remain locale-agnostic and pass translation keys through state for components to resolve. If a store captures a translated string during a background process and the user switches locale mid-flight, the stored value becomes stale and out of sync with the UI.

Using translations in components

Composition API (<script setup>)

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

const { t, locale } = useI18n()
</script>

<template>
  <div>
    <h1>{{ t('homepage.title') }}</h1>
    <p>{{ t('guest.greeting', { name: 'Anna' }) }}</p>

    <button @click="locale = 'es'">Español</button>
    <button @click="locale = 'en'">English</button>
  </div>
</template>

Language switcher component

<!-- components/LanguageSwitcher.vue -->
<template>
  <div class="language-switcher">
    <select v-model="currentLocale">
      <option v-for="lang in availableLanguages" :key="lang.code" :value="lang.code">
        {{ lang.name }}
      </option>
    </select>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'

const { locale } = useI18n()

const availableLanguages = [
  { code: 'en', name: 'English' },
  { code: 'es', name: 'Español' },
  { code: 'fr', name: 'Français' },
  { code: 'pl', name: 'Polski' },
]

const currentLocale = computed({
  get: () => locale.value,
  set: (value) => {
    locale.value = value
    localStorage.setItem('user-locale', value)
  },
})
</script>

For guidance on language selector UX, including why you should not use country flags, see our posts on language selector best practices and why flags hurt UX in language selectors.

For inspiration, check out our selected examples of language switchers in real-world apps.

Translation key architecture

How you name and structure translation keys determines how maintainable your project is at scale. A flat key structure ("save_button", "cancel_button") works for small projects but becomes unnavigable past a few dozen keys. An overly nested three-level structure is verbose to write and read.

The sweet spot for most Vue apps is two-level namespacing that mirrors your feature or page structure:

{
  "common": {},
  "navigation": {},
  "booking": {},
  "rooms": {},
  "guest": {},
  "errors": {}
}

Keep common for strings shared across the app (Save, Cancel, Loading, etc.), and give each feature or page its own namespace. This makes it easy to track translation completeness per feature and assign ownership to the right team.

Warning: Avoid over-nesting translation keys beyond two levels. Deeply nested structures are painful to type, hard to search, and create visual noise in translation editors. Here's a "don't" example:

{
  "pages": {
    "dashboard": {
      "widgets": {
        "analytics": {
          "chart": {
            "tooltip": {
              "label": "Revenue this month"
            }
          }
        }
      }
    }
  }
}

Using t('pages.dashboard.widgets.analytics.chart.tooltip.label') in a template is unreadable and error-prone. Flatten it to t('dashboard.revenueTooltip'); the key is shorter, easier to search, and just as descriptive.

For detailed naming conventions, see our guides on best practices for translation keys and what is a translation key.

SFC <i18n> blocks

When using unplugin-vue-i18n, you can embed translations directly in a Single File Component. This is useful for component-specific strings that don't belong in global locale files:

<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

<template>
  <div>
    <h2>{{ t('title') }}</h2>
    <p>{{ t('description') }}</p>
  </div>
</template>

<i18n lang="json">
{
  "en": {
    "title": "Payment method",
    "description": "Update your billing information for Pillow Hotel"
  },
  "es": {
    "title": "Método de pago",
    "description": "Actualice su información de facturación en Hotel Almohada"
  }
}
</i18n>

Pro-Tip: The Vite plugin pre-compiles <i18n> blocks at build time for better runtime performance. One trade-off: these translations are scoped to the component and don't appear in your global locale files, which makes them harder to export to SimpleLocalize without dedicated extraction tooling. For most teams, keeping translations in global JSON files with clear namespacing is the more maintainable path.

Pluralization

vue-i18n uses a pipe | separator for plural forms in its default message format:

{
  "roomsBooked": "No rooms booked | One room booked | {count} rooms booked"
}
<template>
  <!-- v11: use t() with count, not $tc() -->
  <p>{{ t('roomsBooked', { count }, count) }}</p>
</template>

This pipe syntax covers English and other two-form languages well. For languages with more complex plural rules (e.g., Polish has four forms, Arabic has six), you need the full ICU message format.

Warning: tc() is the old pluralization function from vue-i18n v8 and earlier. In v9+, use t() with the count parameter for pluralization. The tc() function is no longer needed and doesn't support ICU syntax.

ICU defines plural categories (zero, one, two, few, many, other) and lets translators write the appropriate form for each category in their language. The application code stays the same across all languages; only the translation string changes.

// src/i18n/index.ts
export const i18n = createI18n({
  legacy: false,
  locale: 'en',
  fallbackLocale: 'en',
  messages: {
    en: {
      roomsBooked: `{count, plural,
        =0 {No rooms booked}
        one {One room booked}
        other {{count} rooms booked}
      }`,
    },
    es: {
      roomsBooked: `{count, plural,
        one {Una habitación reservada}
        other {{count} habitaciones reservadas}
      }`,
    },
    pl: {
      // Polish requires four plural forms
      roomsBooked: `{count, plural,
        one {Jedna zarezerwowana sala}
        few {{count} zarezerwowane sale}
        many {{count} zarezerwowanych sal}
        other {{count} zarezerwowanych sal}
      }`,
    },
  },
})

SimpleLocalize uses ICU format internally, so translations round-trip correctly between your codebase and the translation editor. For a deeper dive into plural rules across languages, see our pluralization guide and ICU message format guide.

Pluralization example

Locale detection strategies

How your app determines which locale to serve is one of the most consequential i18n architecture decisions. Each approach has real trade-offs in SEO, UX, and implementation complexity.

  • URL-based detection encodes the locale in the path (/en/rooms, /es/rooms) or subdomain (es.example.com). This is the recommended approach for public-facing apps because URLs are indexable, shareable, and cacheable. Search engines can index each locale independently. The trade-off is routing complexity.

  • Accept-Language header detection reads the browser's preferred language list. Zero friction for the user, but unreliable as the sole mechanism; it reflects browser preferences, not necessarily the user's preference for your product. Use it as a starting hint, not a final decision.

  • localStorage persistence stores the user's explicit language choice across sessions. Works well for authenticated apps where locale is a user preference rather than a routing concern. Not indexable by search engines.

  • The production pattern combines all three: use Accept-Language on first visit for an initial guess, encode in the URL for SEO, and persist the user's explicit choice in localStorage. Always let the user override; detection is inference, not fact.

// src/i18n/detection.ts
const SUPPORTED_LOCALES = ['en', 'es', 'fr', 'pl']
const STORAGE_KEY = 'preferred-locale'

export function detectLocale(): string {
  // 1. Check localStorage for explicit user preference
  const stored = localStorage.getItem(STORAGE_KEY)
  if (stored && SUPPORTED_LOCALES.includes(stored)) return stored

  // 2. Try Accept-Language header via navigator.languages
  for (const lang of navigator.languages || [navigator.language]) {
    const code = lang.split('-')[0]
    if (SUPPORTED_LOCALES.includes(code)) return code
  }

  // 3. Fall back to default
  return 'en'
}

export function persistLocale(locale: string) {
  localStorage.setItem(STORAGE_KEY, locale)
}

For a comprehensive breakdown of URL structure options, subdomains, cookies, and the SEO implications of each, see our guide on URLs in localization for multilingual websites.

Nuxt with @nuxtjs/i18n

Nuxt handles most of the detection, routing, and SEO complexity through the @nuxtjs/i18n module.

Installation

npm install @nuxtjs/i18n@next

nuxt.config.ts

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],

  i18n: {
    locales: [
      { code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
      { code: 'es', language: 'es-ES', name: 'Español', file: 'es.json' },
      { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' },
      { code: 'pl', language: 'pl-PL', name: 'Polski', file: 'pl.json' },
    ],
    defaultLocale: 'en',
    strategy: 'prefix_except_default', // /rooms for EN, /es/rooms for ES
    langDir: 'locales/',
    lazy: true, // Load translation files on demand
    baseUrl: 'https://pillowhotel.com',
    seo: true,  // Auto-generate hreflang tags
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root',
      fallbackLocale: 'en',
    },
  },
})

Using translations in Nuxt pages

@nuxtjs/i18n exposes useI18n(), useLocalePath(), and useSwitchLocalePath() globally across all pages and components:

<!-- pages/index.vue -->
<script setup lang="ts">
const { t } = useI18n()
const localePath = useLocalePath()

useSeoMeta({
  title: () => t('meta.title'),
  description: () => t('meta.description'),
})
</script>

<template>
  <div>
    <h1>{{ $t('homepage.title') }}</h1>
    <!-- localePath prepends the locale prefix automatically -->
    <NuxtLink :to="localePath('/contact')">{{ $t('navigation.contact') }}</NuxtLink>
  </div>
</template>

Language switcher for Nuxt

<!-- components/LanguageSwitcher.vue -->
<template>
  <div class="language-switcher">
    <button @click="isOpen = !isOpen">
      {{ currentLocale?.name }}
    </button>
    <div v-if="isOpen" class="dropdown">
      <NuxtLink
        v-for="loc in availableLocales"
        :key="loc.code"
        :to="switchLocalePath(loc.code)"
        @click="isOpen = false"
      >
        {{ loc.name }}
      </NuxtLink>
    </div>
  </div>
</template>

<script setup lang="ts">
const { locale, locales } = useI18n()
const switchLocalePath = useSwitchLocalePath()
const isOpen = ref(false)

const currentLocale = computed(() => locales.value.find(l => l.code === locale.value))
const availableLocales = computed(() => locales.value.filter(l => l.code !== locale.value))
</script>

Use localePath() for all internal navigation links, it automatically applies the correct locale prefix. Use switchLocalePath() to generate links that switch language while staying on the current page.

@nuxtjs/i18n with seo: true automatically generates <link rel="alternate" hreflang="..."> tags for each locale. It also handles Server-Side Rendering (SSR) out of the box: translated content is rendered into the initial HTML response, so search engine crawlers index fully localized pages without needing to execute JavaScript. For more on multilingual SEO, see our guide on hreflang and how to use it.

Typesafe-i18n: the TypeScript-first alternative

For TypeScript teams who want compile-time key safety rather than runtime string lookups, typesafe-i18n is worth considering. Instead of JSON files with string keys, translations live in .ts files, and a code generator creates type definitions from the base locale.

Installation

npm install typesafe-i18n
npx typesafe-i18n --setup-auto --adapter vue

This scaffolds the project structure:

src/i18n/
  en/
    index.ts    # Base locale: source of truth for types
  es/
    index.ts
  i18n-types.ts  # Auto-generated type definitions
  i18n-util.ts

Translation files as TypeScript

// src/i18n/en/index.ts
import type { BaseTranslation } from '../i18n-types'

const en = {
  navigation: {
    home: 'Home',
    rooms: 'Rooms',
    booking: 'Book Now',
  },
  booking: {
    price: 'Price per night: {price:string}',
    roomsBooked: 'You have {count:number} room{{count|s}} booked',
  },
  guest: {
    greeting: 'Welcome back, {name:string}!',
  },
} satisfies BaseTranslation

export default en
// src/i18n/es/index.ts
import type { Translation } from '../i18n-types'

const es = {
  navigation: {
    home: 'Inicio',
    rooms: 'Habitaciones',
    booking: 'Reservar ahora',
  },
  booking: {
    price: 'Precio por noche: {price}',
    roomsBooked: 'Tiene {count} habitaciones reservadas',
  },
  guest: {
    greeting: '¡Bienvenido de nuevo, {name}!',
  },
} satisfies Translation

export default es

Usage in components

<script setup lang="ts">
import { useTypesafeI18n } from '../i18n/i18n-vue'

const { LL, locale, setLocale } = useTypesafeI18n()
</script>

<template>
  <div>
    <h1>{{ LL.navigation.home() }}</h1>
    <!-- TypeScript enforces that 'name' is a string -->
    <p>{{ LL.guest.greeting({ name: 'Anna' }) }}</p>
    <!-- TypeScript enforces that 'count' is a number -->
    <p>{{ LL.booking.roomsBooked({ count: 5 }) }}</p>
  </div>
</template>

The key difference from vue-i18n: LL.guest.greeting() is a function call with typed parameters. If you pass the wrong type or forget a required argument, TypeScript catches it at compile time. Keys removed from the base locale become type errors in all consuming components immediately.

For SimpleLocalize integration with typesafe-i18n, the format identifier is typesafe-i18n in your simplelocalize.yml. See the typesafe-i18n integration docs for the full setup.

Dates, numbers, and currencies

vue-i18n provides d() (datetime) and n() (number) functions that wrap the native Intl API:

// src/i18n/index.ts
export const i18n = createI18n({
  legacy: false,
  locale: 'en',
  numberFormats: {
    en: {
      currency: { style: 'currency', currency: 'USD' },
      decimal: { style: 'decimal', minimumFractionDigits: 2 },
    },
    es: {
      currency: { style: 'currency', currency: 'EUR' },
      decimal: { style: 'decimal', minimumFractionDigits: 2 },
    },
  },
  datetimeFormats: {
    en: {
      short: { year: 'numeric', month: '2-digit', day: '2-digit' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
    },
    es: {
      short: { year: 'numeric', month: '2-digit', day: '2-digit' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' },
    },
  },
})
<script setup>
const { n, d } = useI18n()
const price = 1234.50
const today = new Date()
</script>

<template>
  <!-- en: $1,234.50 | es: 1.234,50 € -->
  <p>{{ n(price, 'currency') }}</p>

  <!-- en: 03/25/2026 | es: 25/03/2026 -->
  <p>{{ d(today, 'short') }}</p>

  <!-- en: Wednesday, March 25, 2026 | es: miércoles, 25 de marzo de 2026 -->
  <p>{{ d(today, 'long') }}</p>
</template>

For more on locale-aware number and date formatting, see our guide on number formatting in JavaScript.

RTL support

Supporting Arabic, Hebrew, Persian, or Urdu requires more than adding a translation file. Layout direction affects the entire UI: padding, margins, icon orientation, flex direction, and animation axes all need to respond to direction changes.

Setting the HTML dir attribute

The cleanest approach is a composable that updates the root element when the locale changes:

// src/composables/useLocaleDirection.ts
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'

const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur'])

export function useLocaleDirection() {
  const { locale } = useI18n()

  function applyDirection(loc: string) {
    const dir = RTL_LOCALES.has(loc) ? 'rtl' : 'ltr'
    document.documentElement.setAttribute('dir', dir)
    document.documentElement.setAttribute('lang', loc)
  }

  watch(locale, applyDirection, { immediate: true })
}

Call this composable in your root App.vue:

<script setup>
import { useLocaleDirection } from './composables/useLocaleDirection'
useLocaleDirection()
</script>

CSS logical properties

Use logical properties throughout your styles so layout adapts automatically without per-locale overrides:

/* Instead of: */
.nav-icon { margin-right: 8px; }

/* Use: */
.nav-icon { margin-inline-end: 8px; }

/* Instead of: */
.sidebar { padding-left: 16px; }

/* Use: */
.sidebar { padding-inline-start: 16px; }

/* Instead of: */
p { text-align: left; }

/* Use: */
p { text-align: start; }

When dir="rtl" is set on the root element, margin-inline-end maps to the left side automatically. No additional CSS overrides needed.

Lazy loading translation files

Bundling all locales into the initial JavaScript payload adds unnecessary bytes for every user, most of whom only need one language. Lazy loading loads each locale file only when it's first requested.

Dynamic import pattern

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

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

const loadedLocales = new Set()

export async function loadLocale(locale: string): Promise {
  if (loadedLocales.has(locale)) return

  const messages = await import(`./locales/${locale}.json`)
  i18n.global.setLocaleMessage(locale, messages.default)
  loadedLocales.add(locale)
}

export async function switchLocale(locale: string): Promise {
  await loadLocale(locale)
  i18n.global.locale.value = locale
  localStorage.setItem('preferred-locale', locale)
}

This means Spanish, French, and Polish translation files are never downloaded by an English-only user. In apps with many supported languages, this can meaningfully reduce initial load times.

Pro-Tip: In Nuxt, lazy loading is handled automatically by setting lazy: true in nuxt.config.ts, no additional code needed. For vanilla Vue 3 apps, the dynamic import pattern above is the standard approach.

Managing translations with SimpleLocalize

With your Vue app properly internationalized, you need a workflow for the ongoing work of managing translations: extracting new strings, getting them translated, and syncing the results back into your codebase. This is where SimpleLocalize comes in.

Install the CLI

# macOS / Linux / Windows (WSL)
curl -s https://get.simplelocalize.io/2.10/install | bash

# Windows (PowerShell)
. { iwr -useb https://get.simplelocalize.io/2.10/install-windows } | iex;

# npm
npm install @simplelocalize/cli

simplelocalize.yml for vue-i18n

Create simplelocalize.yml in your project root:

apiKey: YOUR_PROJECT_API_KEY

uploadFormat: single-language-json
uploadPath: ./src/i18n/locales/en.json
uploadLanguageKey: en
uploadOptions:
  - REPLACE_TRANSLATION_IF_FOUND

downloadPath: ./src/i18n/locales/{lang}.json
downloadLanguageKeys: ['en', 'es', 'fr', 'pl']
downloadFormat: single-language-json

simplelocalize.yml for Nuxt (@nuxtjs/i18n)

apiKey: YOUR_PROJECT_API_KEY

uploadFormat: single-language-json
uploadPath: ./locales/en.json
uploadLanguageKey: en
uploadOptions:
  - REPLACE_TRANSLATION_IF_FOUND

downloadPath: ./locales/{lang}.json
downloadLanguageKeys: ['en', 'es', 'fr', 'pl']
downloadFormat: single-language-json
downloadOptions:
  - CREATE_DIRECTORIES

simplelocalize.yml for typesafe-i18n

apiKey: YOUR_PROJECT_API_KEY

uploadFormat: single-language-json
uploadPath: ./src/i18n/en/index.ts
uploadLanguageKey: en
uploadOptions:
  - REPLACE_TRANSLATION_IF_FOUND

downloadPath: ./src/i18n/{lang}/
downloadLanguageKeys: ['en', 'es', 'fr', 'pl']
downloadFormat: typesafe-i18n

Upload and download

# Upload source (English) strings to SimpleLocalize
simplelocalize upload

# Download all translated languages back
simplelocalize download

Add npm scripts for convenience:

{
  "scripts": {
    "i18n:upload": "simplelocalize upload",
    "i18n:download": "simplelocalize download",
    "i18n:sync": "simplelocalize upload && simplelocalize download"
  }
}

Adding context for translators

A key like navigation.home with the value "Home" is ambiguous, is it a house, a navigation button, or a landing page label? A one-line description resolves this immediately and prevents mistranslations before they reach production.

Add descriptions directly in the SimpleLocalize translation editor, or via the API. The AI translation engine uses these descriptions automatically, for example, a description like "Navigation button linking to the hotel homepage" tells Claude or GPT to use "Inicio" (website home) in Spanish rather than "Hogar" (a house). For more on this, see how context improves AI translation quality.

Visual context: screenshots and tasks

Beyond text descriptions, SimpleLocalize lets you attach screenshots to translation keys so translators can see exactly where a string appears in the UI. A translator looking at a screenshot of a checkout page immediately understands that "Total" is a price summary label, not a generic heading. This eliminates an entire category of ambiguity-driven mistranslations.

Translation key with description and screenshot
Translation key with description and screenshot

You can also create tasks and assign them to specific translators or reviewers, making it easy to coordinate work across languages. For example, after a feature release, create a task for "Translate new booking flow keys" and assign it to your Spanish and French translators. They'll see only the relevant untranslated keys, with screenshots and descriptions attached, and can complete their work without digging through the entire project.

Auto-translation on key upload

When a developer pushes a new key via the CLI (simplelocalize upload), SimpleLocalize can immediately auto-translate that key to all languages defined in your simplelocalize.yml — whether that's 5, 15, or 50 target languages. Configure an automation rule to trigger auto-translation on every upload, and new strings are translated within seconds of being merged.

This means a developer who adds a new booking.promoCodeApplied key in English can run npm run i18n:sync and immediately pull back machine-translated versions in Spanish, French, and Polish — allowing for instant localized testing without waiting for human translators. Translators can then review and refine the auto-translated strings at their own pace, while the development team moves forward without blocking on translations.

Automation example
Automation example

CI/CD integration

Manual upload and download steps don't scale past a handful of languages. Add them to your deployment pipeline to keep translations continuously in sync with code changes.

Automating Localization with GitHub Actions and SimpleLocalize

This pipeline runs whenever the English source file changes: uploads new keys, downloads the latest translations (including auto-translated strings), and commits the updated files back to the repository. New strings added by developers are available to translators within minutes of being merged.

# .github/workflows/localization.yml
name: Sync Translations

on:
  push:
    branches: [main]

env:
  cli-version: '2.10.0'

jobs:
  sync-translations:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Upload source strings
        uses: simplelocalize/github-action-cli@v3
        with:
          api-key: ${{ secrets.SIMPLELOCALIZE_API_KEY }}
          command: 'upload'
          cli-version: ${{ env.cli-version }}
          args: '--uploadPath ./src/i18n/locales/{lang}.json --uploadFormat single-language-json'

      - name: Auto-translate project
        uses: simplelocalize/github-action-cli@v3
        with:
          api-key: ${{ secrets.SIMPLELOCALIZE_API_KEY }}
          command: 'auto-translate'
          cli-version: ${{ env.cli-version }}

      - name: Download translations
        uses: simplelocalize/github-action-cli@v3
        with:
          api-key: ${{ secrets.SIMPLELOCALIZE_API_KEY }}
          command: 'download'
          cli-version: ${{ env.cli-version }}
          args: '--downloadPath ./src/i18n/locales/{lang}.json --downloadFormat single-language-json'

      - name: Commit translation updates
        uses: stefanzweifel/git-auto-commit-action@v5
        with:
          commit_message: "chore: sync translations from SimpleLocalize"
          file_pattern: "src/i18n/locales/*.json"

If you've configured auto-translation automation in SimpleLocalize, the workflow becomes even more powerful:

  • a developer merges a PR with new English strings
  • GitHub Actions uploads them
  • SimpleLocalize auto-translates to all target languages
  • the action downloads the translations and commits them back.

The entire round-trip, from new English key to translated strings in your repository for all languages, happens in a single CI run with zero manual intervention.

You can also use the SimpleLocalize GitHub App for a tighter integration that syncs translations on every push without maintaining a custom workflow file.

For more on this approach and how to structure pipelines for different team sizes, see our guides on continuous localization, GitHub Actions for localization, and the localization workflow for developers.

TypeScript: type-safe translation keys with vue-i18n

vue-i18n has solid TypeScript support in Composition API mode. A type declaration file enables full key autocompletion and catches typos at compile time:

// src/i18n/i18n.d.ts
import en from './locales/en.json'

type MessageSchema = typeof en

declare module 'vue-i18n' {
  export interface DefineLocaleMessage extends MessageSchema {}
}

With this in place:

const { t } = useI18n()

t('navigation.home')    // TypeScript confirms this key exists
t('navigation.homee')   // TypeScript error: key doesn't exist
t('common.save')        // valid

This is one of the most underused features of vue-i18n. It's a five-line addition that catches missing translations at build time instead of silently rendering wrong text in production.

Common mistakes in Vue i18n

Here are some of the most common pitfalls that lead to maintainability issues, translation errors, and poor user experience in Vue i18n projects:

  • Using legacy: true in new Vue 3 projects. The legacy mode exists for migrating Vue 2 codebases. For new projects, always set legacy: false. It unlocks useI18n(), better TypeScript types, and Composition API patterns.

  • Hardcoding strings in <script setup> or templates. Any string that could ever need translation belongs in a locale file. Hardcoded strings are invisible to extraction tools, invisible to translators, and require a code change to fix rather than a translation update.

  • Using raw <RouterLink> in Nuxt without localePath(). This bypasses the locale prefix logic entirely. A link to /rooms in a Spanish locale session should resolve to /es/rooms. Always use localePath() for internal navigation in Nuxt.

  • Forgetting dir when adding RTL languages. Adding Arabic or Hebrew translations without calling document.documentElement.setAttribute('dir', 'rtl') renders translated text with a completely wrong visual layout for native readers.

  • Flat keys for any project beyond a demo. "save", "cancel", "title", "message" multiplied across hundreds of features becomes unnavigable and collision-prone. Two-level namespacing from the start costs nothing and saves significant pain later.

  • Not adding key descriptions. Ambiguous keys like "action", "status", or "label" without context descriptions produce systematically wrong translations. A translator who doesn't know whether "status" refers to a user's account status, a shipping status, or an HTTP status code will make a reasonable guess — and the guess is often wrong.

  • Manual v-if logic for pluralization instead of t(). This is a common and dangerous pattern:

    <!-- DON'T: breaks for languages with more than 2 plural forms -->
    <span v-if="count === 1">{{ count }} item</span>
    <span v-else>{{ count }} items</span>
    

    This works for English, but breaks completely for Polish (4 forms), Arabic (6 forms), or Russian (3 forms). Always use the built-in pluralization:

    <!-- DO: works for all languages -->
    <span>{{ t('items', { count }, count) }}</span>
    

    The t() function delegates to CLDR plural rules, which handle every language correctly. Manual v-if logic hardcodes English assumptions into your template.

  • Bundling all locale files at startup. Importing all language files in createI18n({ messages: { en, es, fr, pl, ja, ... } }) sends every user every language's strings on page load. Use dynamic imports.

  • Using $tc() in v11. The $tc function was removed in v11. Use t('key', { count }, count) instead. The pipe | pluralization format still works, but only through the standard t() function.

  • Using the v-t directive. Deprecated in v11 and will be removed in v12. Use {{ $t('key') }} in templates or t() in <script setup> instead.

Learn more about common localization mistakes and how to avoid them.

Testing

Testing i18n is often overlooked, but it's critical for catching missing keys, layout issues, and mistranslations before they reach production. Here are some strategies for testing Vue i18n:

Unit testing with Vitest

// tests/components/HomePage.spec.ts
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import { createI18n } from 'vue-i18n'
import HomePage from '../src/pages/HomePage.vue'

const i18n = createI18n({
  legacy: false,
  locale: 'en',
  messages: {
    en: { homepage: { title: 'Welcome to Pillow Hotel' } },
    es: { homepage: { title: 'Bienvenido al Hotel Almohada' } },
  },
})

describe('HomePage', () => {
  it('renders title in English', () => {
    const wrapper = mount(HomePage, { global: { plugins: [i18n] } })
    expect(wrapper.text()).toContain('Welcome to Pillow Hotel')
  })

  it('renders title in Spanish after locale switch', async () => {
    i18n.global.locale.value = 'es'
    const wrapper = mount(HomePage, { global: { plugins: [i18n] } })
    expect(wrapper.text()).toContain('Bienvenido al Hotel Almohada')
  })
})

Pseudo-localization for layout testing

Before real translations exist, pseudo-localization replaces strings with visually distinct but readable variants. Brackets mark the extent of each string, accented characters reveal font coverage issues, and hardcoded strings show up immediately as untransformed English text.

// src/i18n/pseudo.ts
const ACCENT_MAP: Record = {
  a: 'à', e: 'é', i: 'î', o: 'ô', u: 'û', c: 'ç', n: 'ñ',
}

export function pseudolocalize(messages: Record): Record {
  function transform(value: unknown): unknown {
    if (typeof value === 'string') {
      return '[' + value.replace(/[aeiouncn]/g, c => ACCENT_MAP[c] || c) + ']'
    }
    if (typeof value === 'object' && value !== null) {
      return Object.fromEntries(
        Object.entries(value as Record).map(([k, v]) => [k, transform(v)])
      )
    }
    return value
  }
  return transform(messages) as Record
}

Enable it in development by adding a pseudo locale. Run this before any real translations exist to catch layout regressions early and identify any strings that were hardcoded and missed extraction. See our pseudo-localization guide for more on this technique.

Best practices summary

Doing i18n right in Vue requires attention to both technical implementation and operational workflow. Here are the key best practices to follow:

  • Set legacy: false for all new Vue 3 projects — always.
  • Use two-level namespaced keys (feature.action) from the very first string.
  • Use the full ICU message format for pluralization in any app targeting more than two languages.
  • Lazy-load locale files with dynamic imports; use lazy: true in Nuxt.
  • Use localePath() and switchLocalePath() in Nuxt for all internal navigation.
  • Update document.documentElement.dir dynamically when locale changes to RTL.
  • Use CSS logical properties (margin-inline-start not margin-left) throughout.
  • Add the TypeScript type declaration for vue-i18n to catch key errors at compile time.
  • Write descriptions for every ambiguous key in SimpleLocalize.
  • Automate upload and download in CI/CD — manual sync does not scale past a few languages.
  • Test with pseudo-localization before real translations exist.

FAQ

What changed between vue-i18n v9/v10 and v11?
v9 and v10 have reached EOL and are no longer maintained. v11 is the current stable release. The key breaking changes in v11 are: tc and $tc were removed (use t() with a count argument instead), Legacy API mode is deprecated (it still works but will be removed in v12), and the v-t directive is deprecated (removed in v12). If you're on v9 or v10, migrating to v11 is straightforward — the Composition API surface is nearly identical, and the official migration guide covers the tc → t change specifically.
Should I use vue-i18n or @nuxtjs/i18n for a Nuxt project?
@nuxtjs/i18n is the right choice for Nuxt. It wraps vue-i18n and adds automatic locale-prefixed routing, hreflang SEO tags, SSR-compatible locale detection, and lazy loading built in. Using raw vue-i18n in Nuxt means rebuilding all of that yourself. If you're on Nuxt, use @nuxtjs/i18n.
What file format should I use with SimpleLocalize for Vue projects?
Use single-language-json for both vue-i18n and @nuxtjs/i18n projects with standard JSON locale files. For typesafe-i18n projects, use the typesafe-i18n format. Both support namespaces via the {ns} path placeholder if you split translations across multiple files.
How do I handle a translation key that is missing in some languages?
vue-i18n falls back to the fallbackLocale (usually English) when a key is missing in the active locale. In development it logs a console warning. In production, configure missingWarn: false to suppress noise, but add a missing-key check step to your CI pipeline — SimpleLocalize's check-missing command can fail the build if any target language has untranslated keys.
Can I use vue-i18n with Pinia stores?
Yes, but avoid injecting the i18n instance directly into stores. Stores should stay locale-agnostic: pass translation keys or raw data through state, and resolve them in components where useI18n() is naturally available. This keeps stores testable and prevents subtle reactivity issues when the locale changes.
Is typesafe-i18n worth it over vue-i18n?
It depends on your team and workflow. typesafe-i18n gives you compile-time key safety, typed parameters, and autocomplete — a significant DX improvement for TypeScript teams on large apps. The trade-off is a more complex setup and TypeScript files instead of JSON, which requires extra tooling to export to SimpleLocalize. vue-i18n with a type declaration file gets you much of the key-safety benefit with a simpler setup. Start with vue-i18n; consider typesafe-i18n if key management at scale becomes a pain point.

Conclusion

Vue's i18n ecosystem gives you everything you need to build genuinely multilingual applications. vue-i18n in Composition API mode, lazy-loaded locale files, ICU pluralization, and @nuxtjs/i18n handling routing and SEO in Nuxt cover the technical foundation. typesafe-i18n is a strong alternative for TypeScript teams that want compile-time safety over runtime string lookups.

The harder challenge is operational: keeping translation files in sync with a codebase that changes daily, ensuring translators have enough context to produce accurate output, and not letting missing translations slip through to production. A simplelocalize.yml config connected to your CI/CD pipeline — uploading new keys on every merge to main and downloading completed translations automatically — turns localization from a periodic manual task into a continuous, low-friction process.

Ready to set up your Vue localization workflow? Check out the full documentation for vue-i18n with SimpleLocalize, @nuxtjs/i18n with SimpleLocalize, or typesafe-i18n with SimpleLocalize, and import your first locale file to get started.

Kinga Pomykała
Kinga Pomykała
Content creator of SimpleLocalize

Get started with SimpleLocalize

  • All-in-one localization platform
  • Web-based translation editor for your team
  • Auto-translation, QA-checks, AI and more
  • See how easily you can start localizing your product.
  • Powerful API, hosting, integrations and developer tools
  • Unmatched customer support
Start for free
No credit card required5-minute setup
"The product
and support
are fantastic."
Laars Buur|CTO
"The support is
blazing fast,
thank you Jakub!"
Stefan|Developer
"Interface that
makes any dev
feel at home!"
Dario De Cianni|CTO
"Excellent app,
saves my time
and money"
Dmitry Melnik|Developer