Internationalisation with vue-i18n
Move every sentence into one dictionary per language with vue-i18n 11, with parameters, plurals, number and date formats, languages loaded on demand and a fallback for missing keys.
What you'll learn
When you finish, you can do these.- 1Set up vue-i18n and ask for a sentence by its key
- 2Put a name and a count into a translated sentence
- 3Format a price and a date for each language
- 4Load languages on demand and fall back to English
Picture the basket page of a small online shop: it greets the visitor by name, says "Your basket is empty" or how many items it holds, and shows a total of €12.50. A visitor from France should read "Votre panier est vide" and 12,50 €. With the English sentences typed into the components, every language needs its own copy of each one. Internationalisation, shortened to i18n because 18 letters sit between the i and the n, moves each sentence into one dictionary per language, and a component asks for a sentence by its key, a short name such as hello for the greeting.
vue-i18n 11 is the library Vue apps use for it. The Code tab holds that page, App.vue, with an English and a French dictionary and an unfinished German one; by the end you can read every file and add German to the page. You need one idea from the lesson Start a project with create-vue: in main.ts, app.use() installs a plugin, an add-on for the whole app.
Set up vue-i18n
The Code tab's i18n.ts holds the setup: createI18n(), a function from vue-i18n, builds the plugin from your dictionaries, and main.ts installs it with createApp(App).use(i18n) before mount() puts the app on the page.
export const i18n = createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
messages: { en } as Record<string, Messages>,
})
legacy: false picks the Composition API mode, where a component calls useI18n() in its <script setup> to get its helpers. The Legacy API is deprecated in version 11 and removed in version 12, so new code starts here.
locale is the language shown now, and fallbackLocale fills gaps in a translation, as a later section shows.
messages maps each language to its dictionary. The English one is locales/en.json, plain JSON a translator can edit without touching Vue code, imported as en. The as Record<string, Messages> part tells TypeScript that more languages arrive later, each a dictionary of strings.
In a component, useI18n() returns three helpers: t for messages (the sentences in a dictionary), n for numbers and d for dates. The heading of App.vue asks for the hello message and hands it a name:
<h1>{{ t('hello', { name: 'Asha' }) }}</h1>
The page shows "Hello, Asha.".
Parameters, plurals and formats
A message takes values as named parameters in braces: "hello": "Hello, {name}." in locales/en.json becomes "Hello, Asha.". Each language can put {name} wherever its grammar wants it.
Never glue a sentence together from pieces such as t('youHave'), a number and t('itemsLeft'): another language may need the words in a different order, and the translator never sees the whole sentence.
Plural forms sit in one message, separated by |. The basket line of locales/en.json has three:
"items": "Your basket is empty | One item in your basket | {count} items in your basket",
App.vue asks for it with t('items', count): the number picks the form and fills {count}. count starts at 2, so the page reads "2 items in your basket".
- t('items', count)
- messages[locale]the current dictionary
- plural choice0, 1 or more
- textparameters filled in
A count of 0 picks the first form, so the "Empty the basket" button turns the line into "Your basket is empty". A count of 1 picks the second. With only two forms, as in "one item | {count} items", 0 uses the second: "0 items".
Prices and dates differ by language too. App.vue holds a price in total (12.5) and the current date in today, and formats them with n(total, 'currency') and d(today, 'long'), which use Intl, the browser's built-in formatter.
currency and long are format names you define for each language in numberFormats and datetimeFormats, two more options of createI18n() in i18n.ts:
numberFormats: {
en: { currency: { style: 'currency', currency: 'EUR' } },
fr: { currency: { style: 'currency', currency: 'EUR' } },
},
Both use euros, yet 12.5 reads €12.50 in English and 12,50 € in French. datetimeFormats defines long the same way. App.vue puts each result into a message, as in t('total', { total: n(total, 'currency') }), so the page reads "Total: €12.50".
Load languages on demand, fall back when a key is missing
A visitor needs one language, so only English comes with the app; the others load when first picked, and English fills their gaps. The language menu in App.vue, a <select>, keeps its choice in lang, and watch(lang, setLocale) calls setLocale() from i18n.ts each time the choice changes:
export async function setLocale(locale: string) {
const load = loaders[locale]
if (load && !i18n.global.availableLocales.includes(locale)) {
const messages = await load()
i18n.global.setLocaleMessage(locale, messages.default)
}
i18n.global.locale.value = locale
document.documentElement.lang = locale
}
In i18n.ts, loaders maps each extra language to a function that imports its dictionary, such as () => import('./locales/fr.json'). The first time a language is picked, availableLocales, the languages already loaded, does not include it yet, so setLocale() runs that function and adds the dictionary with setLocaleMessage(). Each import() becomes its own file in dist/, the folder of the built app (Environment variables, builds and deployment), downloaded once.
Then i18n.global.locale.value = locale switches the language, and the page follows. The last line keeps <html lang> in step: the page's language attribute, which screen readers use to pick a voice and browsers use to offer translation.
locales/de.json in the Code tab is a German dictionary with only three keys, and the menu has no German option yet. With fallbackLocale: 'en', a key missing from it shows the English text instead of the key's name. Formats fall back the same way, so a German page without a de number format shows €12.50.
In development, intlify, the project behind vue-i18n, reports each gap, such as [intlify] Not found 'total' key in 'de' locale messages. The console lists them all; the preview shows the latest.
What goes wrong
The first mistake is an email address in a message, the contact line of locales/en.json:
"contact": "Questions? Write to help@site.com"
The page goes blank, and the preview shows Message compilation error: Invalid linked format. In vue-i18n's message syntax, @ starts a linked message (@:total would insert the total message), and @site.com is not a valid link. Put the @ in quotes inside braces, {'@'}, to print it as plain text:
"contact": "Questions? Write to help{'@'}site.com"
The contact line reads "Questions? Write to help@site.com".
Braces and | are special too: write them as {'{'}, {'}'} and {'|'}.
The second mistake is a named format that was never defined, here a createI18n() call with no numberFormats:
createI18n({ legacy: false, locale: 'en', messages: { en } })
// App.vue: n(total, 'currency')
The total line reads "Total:" with nothing after it, and the preview warns [intlify] Not found 'currency' key in 'en' locale number formats. n() found no currency format in any language and returned an empty string. Define the format in numberFormats for every language you ship:
numberFormats: {
en: { currency: { style: 'currency', currency: 'EUR' } },
fr: { currency: { style: 'currency', currency: 'EUR' } },
},
With the formats defined, the line reads "Total: €12.50".
What to remember
createI18n({ legacy: false })builds the plugin from one dictionary per language, and components ask for a sentence witht('key', params).- Keep each sentence whole in one message with
{name}parameters. Plural forms go in the same message, separated by|. n()andd()format withIntl, but only through the formats you define innumberFormatsanddatetimeFormats.- Load extra languages with one
import()each, set<html lang>when the language changes, and letfallbackLocalecover missing keys. @in a message starts a link. Write{'@'}for a literal one.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add
<option value="de">Deutsch</option>to the select and pick it. The label, greeting and basket line turn German; the other lines stay English, becausede.jsonlacks their keys, and the preview warns[intlify] Fall back to translate 'contact' key with 'en' locale.Add"total": "Summe: {total}"and"today": "Heute ist der {date}."tode.json, with a comma between entries: both lines turn German, yet the price still reads €12.50 and the date is in English. - In
i18n.ts, add adeentry tonumberFormatsand todatetimeFormats, copied fromfr. With Deutsch selected, the total reads12,50 €and the date reads like12. Oktober 2026. - In
locales/en.json, changehelp{'@'}site.comtohelp@site.com. The page goes blank and the preview showsMessage compilation error: Invalid linked format. Put{'@'}back, and the contact line returns.