Skip to content
TutorialProjects & tooling

Internationalisation with vue-i18n 11

Move the words out of your components and into one JSON file per language. Set up with createI18n({ legacy: false }), vue-i18n 11 picks the right message, plural and date format for the language each visitor chooses.

vuejs.school team20 Jan 20266 min readvue-i18n 11.2 · @nuxtjs/i18n 10.2

When every sentence is typed into a template, adding French means editing every component. Internationalisation, i18n for short, separates the words from the code: messages live in a dictionary per language, components ask for a message by key, and switching the language swaps the dictionary. vue-i18n is the standard library for this in Vue, and version 11 is the current major.

You will build a small app that greets the user in English or French, counts items with correct plurals, formats a date and a price for each language, loads the French dictionary only when someone picks it, and remembers the choice. The last section does the same in Nuxt 4.2 with the @nuxtjs/i18n module.

Set up vue-i18n in Composition mode

Install the library and create one file that owns the i18n instance:

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

export const i18n = createI18n({
  legacy: false,
  locale: 'en',
  fallbackLocale: 'en',
  messages: { en },
  datetimeFormats: {
    en: { short: { year: 'numeric', month: 'short', day: 'numeric' } },
    fr: { short: { year: 'numeric', month: 'long', day: 'numeric' } },
  },
  numberFormats: {
    en: { currency: { style: 'currency', currency: 'GBP' } },
    fr: { currency: { style: 'currency', currency: 'EUR' } },
  },
})

legacy: false selects the Composition API mode, where components use useI18n(). Always pass it: without the option, v11 still starts in Legacy mode. The v11 migration page says the Legacy API mode, the one built around this.$t and the Options API, is deprecated in v11 and will be removed in v12, so new code written any other way has a short life.

A locale is a language code such as en or fr-FR. locale is the one the app starts in, and fallbackLocale is where a missing key is looked up before vue-i18n gives up and prints the key itself. datetimeFormats and numberFormats name each locale's formats for d and n, covered below.

Only English messages are loaded up front; French arrives later. Register the plugin once:

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

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

Write messages with parameters and plurals

Messages are plain JSON, one file per language, nested however you like. Parameters sit in braces, and plural forms are separated by a pipe:

src/locales/en.json
{
  "greeting": "Hello, {name}!",
  "cart": {
    "items": "no items | one item | {count} items",
    "total": "Total: {amount}"
  },
  "updated": "Updated on {date}"
}
src/locales/fr.json
{
  "greeting": "Bonjour, {name} !",
  "cart": {
    "items": "aucun article | un article | {count} articles",
    "total": "Total : {amount}"
  },
  "updated": "Mis à jour le {date}"
}

A component gets t from useI18n() and asks by key. For a plural message you pass the number as the second argument; vue-i18n picks the form and fills {count} for you:

src/components/Cart.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'

const { t, d, n } = useI18n()
const count = ref(3)
const total = ref(42.5)
const updatedAt = new Date()
</script>

<template>
  <p>{{ t('greeting', { name: 'Asha' }) }}</p>
  <p>{{ t('cart.items', count) }}</p>
  <p>{{ t('cart.total', { amount: n(total, 'currency') }) }}</p>
  <p>{{ t('updated', { date: d(updatedAt, 'short') }) }}</p>
  <button type="button" @click="count++">+1</button>
</template>

The three pipe-separated forms are zero, one and many. English and French both fit that shape; languages with more forms, such as Russian or Arabic, take a custom pluralRules option, which the vue-i18n docs cover under pluralisation.

The habit to break is building a sentence from pieces in the template:

src/components/Cart.vueBreaks
<p>{{ t('you_have') }} {{ count }} {{ count === 1 ? t('item') : t('items') }}</p>

That hard-codes English word order and English plural rules into the component. French puts the adjective elsewhere, Polish has three plural forms, and the translator never sees the whole sentence. The fix is one message with the number inside it:

src/components/Cart.vueWorks
<p>{{ t('cart.items', count) }}</p>

Every language gets its own complete sentence, with its own rules, in its own file.

Format dates and prices with d and n

d() and n() format a date or a number with the current locale's rules, using the browser's Intl.DateTimeFormat and Intl.NumberFormat underneath. The format names, short and currency in the component, are the keys you defined in datetimeFormats and numberFormats.

Each locale can define them differently: the setup file gives English a short month and sterling, and French a long month name and the euro. So the same n(total, 'currency') prints £42.50 in English and 42,50 € in French, and d(updatedAt, 'short') prints Jan 20, 2026 or 20 janvier 2026, with no code in the component. Both functions also accept a third argument to force a locale, d(updatedAt, 'short', 'fr'), which is handy in a preview.

Load a locale only when it is needed

Shipping every dictionary to every visitor is wasted bandwidth once you have more than two or three. The vue-i18n docs describe the lazy pattern: import the file on demand, register its messages with setLocaleMessage(), and switch the locale only after that. Add a loadLocale function to src/i18n.ts, below the instance, with the import at the top of the file:

src/i18n.ts
import { nextTick } from 'vue'

export async function loadLocale(lang: string) {
  if ((i18n.global.availableLocales as string[]).includes(lang)) return
  const messages = await import(`./locales/${lang}.json`)
  i18n.global.setLocaleMessage(lang, messages.default)
  return nextTick()
}

availableLocales lists the locales that already have messages, so a second switch to French costs nothing. vue-i18n types it with the locale codes in your config ('en' | 'fr'), so without the cast to string[], includes(lang) fails type-checking. The closing nextTick(), as in the docs, waits for Vue's next update.

Vite turns the template-string import into a separate chunk for each file in src/locales/, so fr.json is downloaded the first time someone chooses French and never before. en.json is the exception: i18n.ts already imports it, so it stays in the main bundle, and vite build warns that the dynamic import will not move it into another chunk; that is expected.

In a router-based app, call loadLocale() in a beforeEach guard when the locale comes from the URL, and set the locale after it.

Switch the locale and remember the choice

useI18n() also returns locale, a ref. Change it and every t, d and n in the app re-renders. On top of that, a real switcher loads the messages first, remembers the choice and tells the browser the page's language. This one applies the saved choice when it is created, so put it somewhere always on screen, such as the header:

src/components/LocaleSwitcher.vue
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { loadLocale } from '../i18n'

const { locale } = useI18n()
const saved = localStorage.getItem('locale')
const choice = ref(saved ?? (navigator.language.startsWith('fr') ? 'fr' : 'en'))

watch(choice, async (lang) => {
  await loadLocale(lang)
  locale.value = lang
  localStorage.setItem('locale', lang)
  document.documentElement.lang = lang
}, { immediate: true })
</script>

<template>
  <label>
    Language
    <select v-model="choice">
      <option value="en">English</option>
      <option value="fr">Français</option>
    </select>
  </label>
</template>

How it behaves:

  • The select changes choice; locale follows once loadLocale() has finished. Bound straight to locale, the select would switch first and show English text with French prices until fr.json arrived.
  • choice starts from the saved language, or a guess from the browser for a new visitor.
  • immediate: true runs the watcher once as soon as the switcher is created, which applies the saved choice: a returning French visitor sees English for a moment, then French once fr.json arrives.

Setting document.documentElement.lang matters for screen readers, which switch voices on it, and for the browser's hyphenation and translation prompts.

Do the same in Nuxt with @nuxtjs/i18n 10

In Nuxt the module does the wiring, adds localised routes, and lazy-loads dictionaries without the function above. Version 10 configures vue-i18n 11 for you. Install it, then describe the locales in nuxt.config.ts:

npx nuxi module add @nuxtjs/i18n
nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    defaultLocale: 'en',
    strategy: 'prefix_except_default',
    locales: [
      { code: 'en', language: 'en-GB', name: 'English', file: 'en.json' },
      { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' },
    ],
  },
})

The files live in i18n/locales/ at the project root, next to app/ rather than inside it; i18n is the module's directory and locales the default langDir. language is the full language tag, used for SEO tags and to match the browser's language.

Giving each locale a file is what makes it lazy: the module loads a dictionary when the app starts in that language or when the user switches to it. Version 10 removed the old lazy option, so there is nothing else to turn on.

strategy decides what the URLs look like:

  • prefix_except_default, the default: English at /about, French at /fr/about.
  • prefix: every language gets a prefix, English included.
  • no_prefix: the URL stays the same, and the language comes from a cookie and browser detection.
  • prefix_and_default: every language gets a prefix, and the default language is also served without one.

Pick prefix_except_default unless you have a reason not to; it gives each language its own URLs for search engines and keeps the default clean.

Two more pieces replace the hand-written switcher. Link with <NuxtLinkLocale to="/about">, which adds the right prefix for the current language. And switch with setLocale() from useI18n(), not by writing to locale directly. setLocale() loads the translations, runs the module's hooks and updates the locale cookie, which the detectBrowserLanguage option (on by default) reads on the next visit. With a prefix strategy it also navigates to the same page in the new language.

app/components/LocaleSwitcher.vue
<script setup lang="ts">
const { locale, locales, setLocale } = useI18n()
</script>

<template>
  <button
    v-for="item in locales"
    :key="item.code"
    type="button"
    :disabled="item.code === locale"
    @click="setLocale(item.code)"
  >
    {{ item.name }}
  </button>
</template>

locales is the array from the config, so the buttons grow when you add a language. The JSON files, t, plurals, d and n work the same inside the Nuxt app. Three of the createI18n() options are the exception: fallbackLocale, datetimeFormats and numberFormats go in i18n/i18n.config.ts, not in nuxt.config.ts. That file's default export is defineI18nConfig(() => ({ fallbackLocale: 'en', datetimeFormats, numberFormats })), with the two format objects from src/i18n.ts.

Check it stuck3 questions from the Internationalisation with vue-i18n quiz.
Practice now
Was this helpful?

Keep reading