---
title: "Internationalisation with vue-i18n 11 · vuejs.school"
description: "Set up vue-i18n 11 in Composition mode, keep messages in JSON per language, handle plurals, dates and numbers, load locales lazily, and do the same in Nuxt."
canonical_url: "https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11"
meta:
  "og:description": "Set up vue-i18n 11 in Composition mode, keep messages in JSON per language, handle plurals, dates and numbers, load locales lazily, and do the same in Nuxt."
  "og:image": "https://vuejs.school/_og/d/a_~SW50ZXJuYXRpb25hbGlzYXRpb24gd2l0aCB2dWUtaTE4biAxMSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Internationalisation%2Bwith%2Bvue-i18n%2B11%2Cdescription_~U2V0IHVwIHZ1ZS1pMThuIDExIGluIENvbXBvc2l0aW9uIG1vZGUsIGtlZXAgbWVzc2FnZXMgaW4gSlNPTiBwZXIgbGFuZ3VhZ2UsIGhhbmRsZSBwbHVyYWxzLCBkYXRlcyBhbmQgbnVtYmVycywgbG9hZCBsb2NhbGVzIGxhemlseSwgYW5kIGRvIHRoZSBzYW1lIGluIE51eHQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbnRlcm5hdGlvbmFsaXNhdGlvbi13aXRoLXZ1ZS1pMThuLTExIg%2Cs_o72yPsn6Gke_6uW-.png"
  "og:image:alt": "Internationalisation with vue-i18n 11 · vuejs.school"
  "og:image:height": "600"
  "og:image:type": image/png
  "og:image:width": "1200"
  "og:title": "Internationalisation with vue-i18n 11 · vuejs.school"
  "twitter:image": "https://vuejs.school/_og/d/a_~SW50ZXJuYXRpb25hbGlzYXRpb24gd2l0aCB2dWUtaTE4biAxMSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Internationalisation%2Bwith%2Bvue-i18n%2B11%2Cdescription_~U2V0IHVwIHZ1ZS1pMThuIDExIGluIENvbXBvc2l0aW9uIG1vZGUsIGtlZXAgbWVzc2FnZXMgaW4gSlNPTiBwZXIgbGFuZ3VhZ2UsIGhhbmRsZSBwbHVyYWxzLCBkYXRlcyBhbmQgbnVtYmVycywgbG9hZCBsb2NhbGVzIGxhemlseSwgYW5kIGRvIHRoZSBzYW1lIGluIE51eHQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbnRlcm5hdGlvbmFsaXNhdGlvbi13aXRoLXZ1ZS1pMThuLTExIg%2Cs_o72yPsn6Gke_6uW-.png"
  "twitter:image:alt": "Internationalisation with vue-i18n 11 · vuejs.school"
---

> Index of every page on this site: [llms.txt](https://vuejs.school/llms.txt)

## 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](https://vue-i18n.intlify.dev/guide/migration/breaking11.html) 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.vue**Breaks***

```
<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.vue**Works***

```
<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 stuck**3 questions from the Internationalisation with vue-i18n quiz.

[**Practice now**](https://vuejs.school/learn/tooling/internationalisation-with-vue-i18n?tab=quiz)

**Was this helpful?**

## **Keep reading**

[Tutorial · 8 min read**Editor and Vue DevTools setup in 2026**](https://vuejs.school/community/blog/editor-and-vue-devtools-setup-in-2026) [Release notes · 6 min read**Rolldown 1.0: the bundler under Vite 8 is stable**](https://vuejs.school/community/blog/rolldown-1-0-the-bundler-under-vite-8-is-stable)

```json
{"@context":"https://schema.org","@graph":[{"@id":"https://vuejs.school/#website","@type":"WebSite","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school/","publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11#webpage","@type":"WebPage","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-20","description":"Set up vue-i18n 11 in Composition mode, keep messages in JSON per language, handle plurals, dates and numbers, load locales lazily, and do the same in Nuxt.","name":"Internationalisation with vue-i18n 11","url":"https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11","breadcrumb":{"@id":"https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11#breadcrumb"},"isPartOf":{"@id":"https://vuejs.school/#website"},"potentialAction":[{"@type":"ReadAction","target":["https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11"]}],"primaryImageOfPage":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/#identity","@type":"Organization","description":"A free AI-instructed school for Vue.js. Short tutorials, runnable code and quizzes that adapt to you.","name":"vuejs.school","url":"https://vuejs.school","logo":{"@id":"https://vuejs.school/#logo"}},{"@id":"https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11#article","dateModified":"2026-10-27T19:31:40.253Z","datePublished":"2026-01-20","description":"Set up vue-i18n 11 in Composition mode, keep messages in JSON per language, handle plurals, dates and numbers, load locales lazily, and do the same in Nuxt.","headline":"Internationalisation with vue-i18n 11","inLanguage":"en","isAccessibleForFree":true,"thumbnailUrl":"https://vuejs.school/_og/d/a_~SW50ZXJuYXRpb25hbGlzYXRpb24gd2l0aCB2dWUtaTE4biAxMSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Internationalisation%2Bwith%2Bvue-i18n%2B11%2Cdescription_~U2V0IHVwIHZ1ZS1pMThuIDExIGluIENvbXBvc2l0aW9uIG1vZGUsIGtlZXAgbWVzc2FnZXMgaW4gSlNPTiBwZXIgbGFuZ3VhZ2UsIGhhbmRsZSBwbHVyYWxzLCBkYXRlcyBhbmQgbnVtYmVycywgbG9hZCBsb2NhbGVzIGxhemlseSwgYW5kIGRvIHRoZSBzYW1lIGluIE51eHQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbnRlcm5hdGlvbmFsaXNhdGlvbi13aXRoLXZ1ZS1pMThuLTExIg%2Cs_o72yPsn6Gke_6uW-.png","@type":["Article","BlogPosting"],"articleSection":["Tutorial"],"author":{"@id":"https://vuejs.school/#identity"},"image":{"@id":"https://vuejs.school/#/schema/image/1"},"isPartOf":{"@id":"https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11#webpage"},"mainEntityOfPage":{"@id":"https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11#webpage"},"publisher":{"@id":"https://vuejs.school/#identity"}},{"@id":"https://vuejs.school/community/blog/internationalisation-with-vue-i18n-11#breadcrumb","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","name":"Blog","item":"https://vuejs.school/community/blog","position":1},{"@type":"ListItem","name":"Internationalisation with vue-i18n 11","position":2}]},{"@id":"https://vuejs.school/#logo","@type":"ImageObject","caption":"vuejs.school","contentUrl":"https://vuejs.school/apple-touch-icon.png","inLanguage":"en","url":"https://vuejs.school/apple-touch-icon.png"},{"@id":"https://vuejs.school/#/schema/image/1","@type":"ImageObject","contentUrl":"https://vuejs.school/_og/d/a_~SW50ZXJuYXRpb25hbGlzYXRpb24gd2l0aCB2dWUtaTE4biAxMSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Internationalisation%2Bwith%2Bvue-i18n%2B11%2Cdescription_~U2V0IHVwIHZ1ZS1pMThuIDExIGluIENvbXBvc2l0aW9uIG1vZGUsIGtlZXAgbWVzc2FnZXMgaW4gSlNPTiBwZXIgbGFuZ3VhZ2UsIGhhbmRsZSBwbHVyYWxzLCBkYXRlcyBhbmQgbnVtYmVycywgbG9hZCBsb2NhbGVzIGxhemlseSwgYW5kIGRvIHRoZSBzYW1lIGluIE51eHQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbnRlcm5hdGlvbmFsaXNhdGlvbi13aXRoLXZ1ZS1pMThuLTExIg%2Cs_o72yPsn6Gke_6uW-.png","inLanguage":"en","url":"https://vuejs.school/_og/d/a_~SW50ZXJuYXRpb25hbGlzYXRpb24gd2l0aCB2dWUtaTE4biAxMSDCtyB2dWVqcy5zY2hvb2w%2Cc_OgCard.takumi%2Ctitle_Internationalisation%2Bwith%2Bvue-i18n%2B11%2Cdescription_~U2V0IHVwIHZ1ZS1pMThuIDExIGluIENvbXBvc2l0aW9uIG1vZGUsIGtlZXAgbWVzc2FnZXMgaW4gSlNPTiBwZXIgbGFuZ3VhZ2UsIGhhbmRsZSBwbHVyYWxzLCBkYXRlcyBhbmQgbnVtYmVycywgbG9hZCBsb2NhbGVzIGxhemlseSwgYW5kIGRvIHRoZSBzYW1lIGluIE51eHQu%2Ceyebrow_~VHV0b3JpYWwgwrcgNiBtaW4gcmVhZA%2Ctint_sand%2Cp_Ii9jb21tdW5pdHkvYmxvZy9pbnRlcm5hdGlvbmFsaXNhdGlvbi13aXRoLXZ1ZS1pMThuLTExIg%2Cs_o72yPsn6Gke_6uW-.png"}]}
```