Vue i18n guide

Setting up vue-i18n in a Vue 3 app: Composition API usage, pluralization, named interpolation, lazy-loaded locales and the legacy-mode migration trap.

vue-i18n is the standard internationalization plugin for Vue. Version 9 and later target Vue 3 and default to the Composition API, which is where most of the confusion with older tutorials comes from.

Setup

bash
npm install vue-i18n@9
js
// src/i18n.js
import { createI18n } from "vue-i18n";
import en from "./locales/en.json";

export default createI18n({
  legacy: false,          // required for the Composition API
  globalInjection: true,  // makes $t available in templates
  locale: "en",
  fallbackLocale: "en",
  messages: { en },
});
js
// src/main.js
import { createApp } from "vue";
import i18n from "./i18n";
import App from "./App.vue";

createApp(App).use(i18n).mount("#app");

legacy: false is the setting that matters. Leave it out and the instance runs in Vue 2 compatibility mode, useI18n() throws, and every Composition API example you find online fails in a way that does not obviously point at this option.

Using it

In a template, with globalInjection:

vue
<template>
  <h1>{{ $t("dashboard.title") }}</h1>
  <p>{{ $t("dashboard.welcome", { name: user.firstName }) }}</p>
</template>

In setup:

vue
<script setup>
import { useI18n } from "vue-i18n";

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

<template>
  <h1>{{ t("dashboard.title") }}</h1>
</template>

t translates, n formats numbers, d formats dates, and locale is a ref you can assign to switch language.

The message file is ordinary nested JSON:

json
{
  "dashboard": {
    "title": "Dashboard",
    "welcome": "Welcome back, {name}"
  }
}

Note the interpolation syntax: single braces, unlike i18next’s double braces. If you are migrating between the two, this is the first thing to convert.

Interpolation

Named interpolation is the form to use:

json
{ "invitation": "{inviter} invited you to {project}" }

List interpolation exists — {0}, {1} with an array argument — and should be avoided for the usual reason: positional placeholders cannot be reordered by a translator, and word order differs between languages. See the placeholder formats cheat sheet.

There is also linked messages, which let one message reference another:

json
{
  "brand": "Acme",
  "footer": "@:brand — all rights reserved"
}

Useful for a product name that appears in many strings and might change. Use it sparingly: a translator seeing @:brand has no idea what will be substituted, and in languages with grammatical gender the linked value can force the surrounding words to change.

Pluralization

vue-i18n separates plural forms with a pipe inside a single string:

json
{
  "messages": "no messages | one message | {count} messages"
}
js
t("messages", 0);   // => "no messages"
t("messages", 1);   // => "one message"
t("messages", 5);   // => "5 messages"

With three forms the first is used for zero; with two forms, the first is singular and the second plural.

This syntax is compact and it is also the weakest part of vue-i18n for serious localization. The built-in rule handles two, occasionally three forms. Languages needing four or six — Polish, Arabic, Russian, Welsh — require a custom rule registered on the instance:

js
createI18n({
  legacy: false,
  pluralRules: {
    ru(choice, choicesLength) {
      const n = Math.abs(choice) % 100;
      const n1 = n % 10;
      if (n > 10 && n < 20) return 2;
      if (n1 > 1 && n1 < 5) return 1;
      if (n1 === 1) return 0;
      return 2;
    },
  },
});

Writing these by hand for every language is error-prone, and getting one subtly wrong produces a bug that only shows up at certain numbers. If you support languages with complex plural rules, strongly consider the ICU message format support instead — @intlify/message-compiler accepts ICU syntax, which expresses plural categories by name and delegates the rules to CLDR. See ICU MessageFormat and plural rules by language.

Numbers and dates

Declare formats on the instance, then use them by name:

js
createI18n({
  numberFormats: {
    en: { currency: { style: "currency", currency: "USD" } },
    fr: { currency: { style: "currency", currency: "EUR" } },
  },
  datetimeFormats: {
    en: { short: { year: "numeric", month: "short", day: "numeric" } },
  },
});
vue
{{ n(total, "currency") }}
{{ d(createdAt, "short") }}

These delegate to Intl under the hood, so you get correct separators, symbol placement and calendar behaviour per locale for free.

Lazy loading locales

Shipping every language in the bundle is wasteful once you pass two or three. Load on demand:

js
export async function setLocale(locale) {
  if (!i18n.global.availableLocales.includes(locale)) {
    const messages = await import(`./locales/${locale}.json`);
    i18n.global.setLocaleMessage(locale, messages.default);
  }
  i18n.global.locale.value = locale;
  document.querySelector("html").setAttribute("lang", locale);
}

Call it from a router navigation guard so messages are present before the destination route renders:

js
router.beforeEach(async (to) => {
  await setLocale(to.params.locale ?? "en");
});

Setting the lang attribute on <html> is not optional decoration — it drives screen reader pronunciation, browser translation prompts, and font selection for CJK text.

Single-file component blocks

vue-i18n supports an <i18n> block colocating messages with the component:

vue
<i18n>
{ "en": { "title": "Settings" } }
</i18n>

It demos well and it is a poor fit for real translation workflows: the strings are scattered across hundreds of .vue files, so there is no file to hand a translator and no way to see the whole message set. Keep messages in dedicated locale files.

Syncing translations

bash
wti push      # source locale up
wti pull      # translated locales back
wti diff      # what a push would change

Push on merge to your designated branch, pull nightly into a pull request. The Git-based localization workflow covers why only one branch should push.

Type safety

vue-i18n can derive its key type from a schema, so a mistyped key becomes a compile error rather than a string rendering the key itself:

ts
import type en from "./locales/en.json";

type MessageSchema = typeof en;

const i18n = createI18n<[MessageSchema], "en" | "fr">({
  legacy: false,
  locale: "en",
  messages: { en: enMessages },
});

The generic parameters give you autocomplete on t() and an error when a key is removed from the JSON but still referenced. Ten lines, and it removes a whole class of silent failure.

Build-time message compilation

By default vue-i18n ships a runtime message compiler that parses your message strings in the browser. You can compile them at build time instead, which is both faster and smaller:

js
// vite.config.js
import vueI18n from "@intlify/unplugin-vue-i18n/vite";

export default {
  plugins: [
    vueI18n({ include: path.resolve(__dirname, "./src/locales/**") }),
  ],
};

This swaps the full build for the runtime-only one and precompiles messages into functions. The trade is that messages must be known at build time, so it does not combine with fetching locale files from an API — but it does combine fine with the dynamic import() lazy loading above, since those are still build-time modules.

It also surfaces malformed messages as build errors rather than runtime ones, which is worth having on its own.

Testing

Mount components with a real i18n instance rather than stubbing $t. Stubbing hides exactly the failures you want to catch — a missing key, a plural form that does not resolve:

js
import { createI18n } from "vue-i18n";
import { mount } from "@vue/test-utils";

const i18n = createI18n({ legacy: false, locale: "en", messages: { en } });

mount(Component, { global: { plugins: [i18n] } });

Assert on behaviour rather than on translated copy, so a wording change does not break the test. Then add a separate completeness check in CI: every key present in every locale, and every pluralized key carrying the form count its language requires.

Common mistakes

  • Omitting legacy: false, then wondering why useI18n() throws.
  • Assuming the default plural rule is enough for languages with more than two forms.
  • Using <i18n> blocks, which fragments the message set beyond a translator’s reach.
  • Positional {0} interpolation, which cannot be reordered.
  • Forgetting the lang attribute when switching locale.
  • Formatting numbers manually instead of using n.

Frequently asked questions

How do I use vue-i18n with the Composition API?
Create the i18n instance with legacy: false, then call useI18n() inside setup to get t, n and d. Without legacy: false the instance runs in Vue 2 compatibility mode and useI18n throws.
How does pluralization work in vue-i18n?
Plural forms are separated by a pipe character in a single string, and selected by the number passed to t. The default rule set handles two forms; languages needing more require a custom pluralization rule registered on the instance.
What is the difference between $t and t in vue-i18n?
$t is the global property available in templates when using legacy mode or global injection. t is the function returned by useI18n() in the Composition API. They resolve the same messages; the difference is how you obtain them.
How do I lazy load locale files in vue-i18n?
Import the locale's messages dynamically, register them with i18n.global.setLocaleMessage, then switch the locale. Doing this in a router navigation guard means the messages are loaded before the destination route renders.

Keep reading

Translate your app without the spreadsheet round-trip

WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.