All articles
10 min read

Vue i18n: A Practical Translation Guide

Vue I18n setup for Vue 3 and Nuxt, message format and pluralization, lazy-loading locales, and keeping translation files maintainable as the app grows.

Vuei18nJSON

Vue I18n is the de facto localization library for Vue. This guide walks through a Vue 3 setup with the Composition API, the message format you will actually use day to day, pluralization for languages that need more than two forms, lazy-loading locale bundles so you do not ship every language to every user, and the Nuxt module.

The last two sections are the ones most guides skip: what happens to your JSON files after six months of feature work, and how translations get in and out of them.

Installing and creating the i18n instance

Vue I18n v9 was the Vue 3 rewrite; v10 and v11 build on the same API surface. Install the current major:

npm install vue-i18n@11

Create the instance and pass it to the app:

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

export const SUPPORTED_LOCALES = ['en', 'de', 'fr', 'pl'] as const
export type Locale = (typeof SUPPORTED_LOCALES)[number]

export const i18n = createI18n({
  legacy: false,
  locale: 'en',
  fallbackLocale: 'en',
  messages: { en },
  missingWarn: import.meta.env.DEV,
  fallbackWarn: import.meta.env.DEV,
})
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n'

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

Legacy mode vs Composition mode

legacy: false is the single most important option. It switches the instance to Composition mode.

ConcernLegacy mode (legacy: true)Composition mode (legacy: false)
API styleVue 2 style: this.$t, this.$i18n.localeuseI18n() in setup
Locale valueplain string on the instancea ref, so locale.value = 'de'
TypeScriptweaker inferencetyped message schemas
Tree-shakingkeeps the Options API gluesmaller runtime

Legacy mode exists so Vue 2 codebases can migrate incrementally. For anything new, use Composition mode. Mixing the two in one app is possible but confusing – pick one and stay there.

In Composition mode, $t is still available in templates because the plugin installs a global instance. That is convenient and worth using; reach for useI18n() when you need the translation function in script code.

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

const { t, locale } = useI18n()

const pageTitle = computed(() => t('dashboard.title'))

function switchTo(next: string) {
  locale.value = next
  document.documentElement.lang = next
}
</script>

<template>
  <h1>{{ pageTitle }}</h1>
  <p>{{ $t('dashboard.subtitle') }}</p>
  <button @click="switchTo('de')">Deutsch</button>
</template>

Message format: interpolation and linking

Messages live in plain JSON. Vue I18n's message compiler understands named interpolation, list interpolation and links between messages.

{
  "greeting": "Hello, {name}!",
  "range": "Showing {0}–{1} of {2}",
  "brand": "Acme",
  "footer": "© 2026 @:brand. All rights reserved.",
  "literal": "Prices are shown in {'$'}USD"
}
<template>
  <p>{{ $t('greeting', { name: user.firstName }) }}</p>
  <p>{{ $t('range', [start, end, total]) }}</p>
  <p>{{ $t('footer') }}</p>
</template>

Three details that save debugging time:

  • @:brand is a linked message. It inlines another key, which keeps a product name in exactly one place. Modifiers exist too – @.upper:brand renders ACME.
  • A literal in single quotes inside braces escapes characters the compiler would otherwise treat as syntax. Use it for stray @, | or $ characters.
  • The pipe character | splits plural branches, so a message containing a literal pipe must escape it the same way.

Never build a sentence by concatenating two translated fragments. Word order differs between languages, and a translator seeing half a sentence cannot make a correct choice. One sentence, one key, with interpolation for the variable parts.

Pluralization, including the hard languages

The default plural syntax uses | to separate branches, and $tc from Vue 2 has been folded into t:

{
  "items": "no items | one item | {count} items"
}

The overload order matters here. t accepts the named values second and the plural count third – passing the count second and an object third makes Vue I18n read that object as translation options (locale, warnings) rather than as interpolation values, and {count} silently renders empty:

<template>
  <!-- named values, then the count -->
  <span>{{ $t('items', { count: cartCount }, cartCount) }}</span>
</template>

Vue I18n also injects the count automatically as both n and count, so $t('items', cartCount) works for this message. Passing the object explicitly is worth the extra characters when the message has other placeholders too.

With three branches the library treats them as zero / one / many. With two branches it is one / many. This works for English, German, and most Germanic and Romance languages – but it is wrong for Slavic, Baltic, Arabic, and Welsh, which have different category sets. Polish, for example, distinguishes one, a "few" form for 2–4 (excluding teens), and a "many" form.

You supply a rule per locale via pluralRules. The function receives the count and the number of available branches, and returns the branch index:

function polishPluralRule(choice: number, choicesLength: number): number {
  if (choice === 0) return 0
  const mod10 = choice % 10
  const mod100 = choice % 100
  if (choice === 1) return 1
  if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 2
  return choicesLength < 4 ? 2 : 3
}

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

The Polish message then needs four branches in the same order the rule returns:

{
  "items": "brak przedmiotów | {count} przedmiot | {count} przedmioty | {count} przedmiotów"
}

The rule and the message file are coupled: if a translator adds or removes a branch, the indices shift and the output silently goes wrong. Write a unit test per pluralized locale that asserts a handful of counts (0, 1, 2, 5, 22, 112) produce the expected string. It is ten lines and it catches the whole class of bug.

Numbers, dates and currency

Do not format these by hand. Vue I18n wraps Intl.NumberFormat and Intl.DateTimeFormat, so you declare named formats once:

const numberFormats = {
  en: {
    currency: { style: 'currency', currency: 'USD', currencyDisplay: 'symbol' },
    percent: { style: 'percent', maximumFractionDigits: 1 },
  },
  de: {
    currency: { style: 'currency', currency: 'EUR', currencyDisplay: 'symbol' },
    percent: { style: 'percent', maximumFractionDigits: 1 },
  },
}

const datetimeFormats = {
  en: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
    long: { dateStyle: 'full', timeStyle: 'short' },
  },
  de: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
    long: { dateStyle: 'full', timeStyle: 'short' },
  },
}

Pass both to createI18n as numberFormats and datetimeFormats, then reference them by name:

<template>
  <span>{{ $n(order.total, 'currency') }}</span>
  <time>{{ $d(order.createdAt, 'short') }}</time>
</template>

The currency code is a property of the price, not of the interface language – a German user viewing a USD invoice should see dollars. To override a single call, pass one options object that carries both the format name and the overrides, since the third positional argument of $n is a locale, not an options bag:

<template>
  <span>{{ $n(order.total, { key: 'currency', currency: order.currency }) }}</span>
</template>

Lazy-loading locale messages

Bundling every locale into the main chunk is the most common Vue I18n performance mistake. If each locale file weighs a few tens of kilobytes, ten languages add up to a payload where all but one language is dead weight for any given visitor. Load the active locale on demand with a dynamic import:

// src/locale.ts
import { nextTick } from 'vue'
import { i18n, SUPPORTED_LOCALES, type Locale } from './i18n'

const loaded = new Set<string>(['en'])

export async function setLocale(locale: Locale) {
  if (!SUPPORTED_LOCALES.includes(locale)) return

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

  i18n.global.locale.value = locale
  document.documentElement.setAttribute('lang', locale)
  localStorage.setItem('locale', locale)
  await nextTick()
}

Keep this in its own module rather than in i18n.ts itself – the instance file is imported by main.ts before the app mounts, and splitting the loader out keeps that import graph acyclic.

Two constraints worth knowing. First, the import path must contain a static prefix and suffix so the bundler can enumerate candidates – import(localeUrl) with a fully dynamic string produces no chunks. Second, validate the locale against your allowlist before it reaches the import, as above; feeding an unvalidated route parameter into a dynamic path is how you end up requesting chunks that do not exist.

Call setLocale from your router's beforeEach guard when the locale is part of the URL, so a deep link to /de/pricing renders in German on first paint rather than flashing English.

Nuxt with @nuxtjs/i18n

Nuxt gets its own module, which wires routing, SEO and lazy loading together:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    strategy: 'prefix_except_default',
    defaultLocale: 'en',
    locales: [
      { code: 'en', language: 'en-US', file: 'en.json' },
      { code: 'de', language: 'de-DE', file: 'de.json' },
      { code: 'fr', language: 'fr-FR', file: 'fr.json' },
    ],
    lazy: true,
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root',
    },
  },
})

Files go in i18n/locales/ and are loaded per route, not all at once.

Routing strategies

  • prefix_except_default/pricing and /de/pricing. The usual choice: clean canonical URLs for your primary market, prefixed paths for the rest.
  • prefix – every locale prefixed, including the default. The most predictable, and the easiest to reason about in analytics and redirects.
  • prefix_and_default – both /pricing and /en/pricing resolve. Convenient, but you must get canonical tags right or you have duplicate content.
  • no_prefix – locale lives in a cookie only. Fine for a logged-in dashboard, bad for anything you want indexed, because search engines cannot reach the other languages.

SEO tags

For public pages, emit hreflang alternates and a canonical URL. The module generates them from your locales config – the language field on each entry is what ends up in the hreflang attribute, which is why it holds a full tag like de-DE rather than just the route code:

<script setup lang="ts">
const head = useLocaleHead()
</script>

<template>
  <Html :lang="head.htmlAttrs.lang" :dir="head.htmlAttrs.dir">
    <Head>
      <template v-for="link in head.link" :key="link.id">
        <Link v-bind="link" />
      </template>
    </Head>
  </Html>
</template>

Link between locales with useLocalePath() rather than hardcoding prefixes, and use useSwitchLocalePath() for the language switcher so it lands on the translated equivalent of the current page instead of the home page.

SFC i18n blocks, and when not to use them

Vue I18n supports a per-component message block – a custom block in the single-file component, written as an i18n tag with a lang="json" attribute, containing a JSON object keyed by locale. It needs the @intlify/unplugin-vue-i18n plugin in your Vite config.

The appeal is obvious: the strings sit next to the markup that uses them. The trade-offs are real:

  • Translators cannot work on .vue files. Any workflow that hands strings to a person or a service now has to extract from source and merge back.
  • The same string gets duplicated across components instead of shared.
  • Messages are compiled into the component chunk, so they cannot be lazily loaded per locale independently of the component.
  • Global search for a user-visible string returns dozens of scattered blocks.

They are genuinely useful for a self-contained widget in a design system, or a demo. For an application that ships in more than two languages, keep messages in central JSON files.

Keeping the locale files maintainable

Six months in, the failure mode is always the same: en.json has 900 keys, de.json has 840, nobody knows which 60 are missing, and three keys are dead code. Some habits that prevent it:

Namespace by feature, not by page. checkout.payment.cardExpiredError survives a redesign; page3.error2 does not. Two or three levels of nesting is the sweet spot – deeper and the keys get unwieldy, flatter and you lose grouping.

Never put English in the key. t('Save changes') looks elegant until the copy changes and every locale file needs a rename.

Treat the source locale as the schema. One file is authoritative; every other locale is derived from it. A short script that diffs key sets and fails CI on missing or orphaned keys is worth writing on day one:

import en from './locales/en.json'
import de from './locales/de.json'

const flatten = (obj: object, prefix = ''): string[] =>
  Object.entries(obj).flatMap(([k, v]) =>
    typeof v === 'object' && v !== null
      ? flatten(v, `${prefix}${k}.`)
      : [`${prefix}${k}`]
  )

const source = new Set(flatten(en))
const target = new Set(flatten(de))

const missing = [...source].filter((k) => !target.has(k))
const orphaned = [...target].filter((k) => !source.has(k))

if (missing.length || orphaned.length) {
  console.error({ missing, orphaned })
  process.exit(1)
}

Give translators context. A key named status.pending could be an order, an invitation or a job. Ship a short note per ambiguous key, in a sidecar file or in your TMS, and the translation quality changes noticeably.

Turn on missingWarn in development only. In production it is noise; in development it is how you find the key you forgot to add.

Expect merge conflicts on the locale files. Two feature branches both add keys to en.json. Git sees two edits to the same region of the same file and hands the conflict to whoever rebased last – someone with no idea what the other feature's keys are for. Nested JSON makes the resolution fiddly: the markers land inside an object, and a hasty fix drops a brace or quietly deletes the other branch's subtree. Then it repeats for de.json and fr.json. Sorting keys deterministically and keeping one key per line reduces the damage, but every translated file stays a shared mutable resource that every branch touches.

The workflow around the files

Editing de.json by hand works up to about two locales and one developer. Past that you need somewhere translations live that is not a merge conflict. A translation management platform sits between the repo and the people writing the copy: strings are imported, translated, reviewed with a per-string status, and exported back as JSON your build consumes unchanged. The same store can hand an agency a CSV or XLIFF export without you keeping a second copy of the strings.

For the mechanics of getting AI-assisted first drafts into JSON and reviewing them, see translating JSON files with AI and the broader AI localization workflow. If you are working across frameworks, the React and Next.js guides cover the same ground for those stacks.

Where Translatize fits

Translatize is a translation management platform built around git-like branching, which addresses the conflict problem directly. A feature branch gets its own set of labels; a translator can own that branch and still have no path to production strings, because only an admin or owner merges to main. Merges take an explicit strategy – non-destructive union, destructive replace, keep-newer by timestamp, or manual key-by-key resolution – so two features that both added keys combine without anyone hand-editing conflict markers.

For Vue, JSON import and export handle nested or flat shapes and carry every language in one file, so messages needs no transformation step; @translatize/cli pulls and pushes the per-language JSON files on disk that your dynamic imports already read, and the same nine formats include CSV and XLIFF 1.2 for an agency. Context fields – description, screenshot, character limit, location – travel with the key, so status.pending is not ambiguous to a translator.

Wrapping up

The Vue-specific parts of localization are small: createI18n with legacy: false, useI18n() in setup, named interpolation, a plural rule for each language that needs one, $n and $d for numbers and dates, and dynamic imports so you ship one locale instead of ten. Nuxt's module adds routing and SEO on top with a config block.

The part that actually determines whether the project stays pleasant is everything after that: a source locale that is treated as a schema, a CI check that catches drift, real context for translators, and a place for translations to live that is not a pull request. Get those right early – retrofitting them onto 900 keys is considerably less fun.

Keep reading