07 · Internationalization¶
Internationalization (i18n) is making an app able to work in many languages and regions; localization is doing the work for a particular one. The engineering part is mostly not about translating strings — it's about never baking English grammar, number formats, date formats or text direction into your components.
This lesson uses vue-i18n (11.4.12 here), the de-facto i18n library for Vue, with the
Composition API. Output below was produced by rendering components with
vue/server-renderer on Vue 3.5.43 in Node, so what you see is the real rendered HTML.
Everything formatting-related is delegated to the browser's (or Node's) Intl APIs, so
exact output such as month abbreviations can vary slightly between runtime versions.
Setup¶
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
export const i18n = createI18n({
legacy: false, // Composition API mode (useI18n)
locale: 'en',
fallbackLocale: 'en',
messages: { en },
numberFormats: {
en: { currency: { style: 'currency', currency: 'EUR' } },
de: { currency: { style: 'currency', currency: 'EUR' } },
},
datetimeFormats: {
en: { short: { year: 'numeric', month: 'short', day: 'numeric' } },
de: { short: { year: 'numeric', month: 'short', day: 'numeric' } },
},
})
Message files are plain nested objects keyed by meaning, not by English text:
{
"cart": {
"title": "Your cart",
"items": "no items | one item | {count} items",
"total": "Total: {amount}"
},
"greeting": "Hello, {name}!"
}
{
"cart": {
"title": "Ihr Warenkorb",
"items": "keine Artikel | ein Artikel | {count} Artikel",
"total": "Summe: {amount}"
}
}
Using it in components¶
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const { t, n, d } = useI18n()
const props = defineProps<{ count: number; total: number; updated: Date }>()
</script>
<template>
<section>
<h2>{{ t('cart.title') }}</h2>
<p>{{ t('cart.items', count) }}</p>
<p>{{ t('cart.total', { amount: n(total, 'currency') }) }}</p>
<p>{{ d(updated, 'short') }}</p>
<p>{{ t('greeting', { name: 'Ada' }) }}</p>
</section>
</template>
Rendering the same component (with counts 0, 1 and 5, a total of 1234.5, and the date
29 September 2026) under each locale produced:
[en] <h2>Your cart</h2><p>no items</p><p>one item</p><p>5 items</p><p>Total: €1,234.50</p><p>Sep 29, 2026</p><p>Hello, Ada!</p>
[de] <h2>Ihr Warenkorb</h2><p>keine Artikel</p><p>ein Artikel</p><p>5 Artikel</p><p>Summe: 1.234,50 €</p><p>29. Sept. 2026</p><p>Hello, Ada!</p>
Notice what came for free: the German total puts the euro sign after the number with a
comma decimal separator; the date order and abbreviation changed. None of that is in the
message files — n() and d() hand the value to Intl.NumberFormat and
Intl.DateTimeFormat with the current locale.
The German file has no greeting key, so the German render fell back to English, and the
console said so:
[intlify] Not found 'greeting' key in 'de' locale messages.
[intlify] Fall back to translate 'greeting' key with 'en' locale.
Keep those warnings on in development — they are your list of missing translations. In
production builds you'll usually silence them (missingWarn: false, fallbackWarn: false)
and rely on a CI check instead (see the exercise).
Plurals are not "singular | plural"¶
vue-i18n's pipe syntax picks a variant by index. Its default rule is English-shaped: with three variants it chooses 0 / 1 / "everything else"; with two, singular / plural. Many languages don't work that way. Polish has different forms for 1, for 2–4 (and 22–24, …), and for 5–21. With the message
the default rule produced, for 0, 1, 2, 5, 22, 25:
"5 pliki" and "25 pliki" are grammatically wrong. The platform already knows the rules:
So map Intl.PluralRules categories to your variant order and register it:
const pr = new Intl.PluralRules('pl')
const polishRule = (choice: number) => {
if (choice === 0) return 0 // our dedicated "none" variant
return { one: 1, few: 2, many: 3, other: 3 }[pr.select(choice)]!
}
createI18n({ /* ... */ pluralRules: { pl: polishRule } })
Result:
Write one such rule per non-English-shaped language you support, driven by Intl, rather
than hand-coding n % 10 logic. For richer grammar (gender, nested plurals), teams often
move to ICU MessageFormat via a translation-management platform; the principle — let the
data say which form to use — is the same.
Loading locales lazily¶
Bundling every language into the main chunk wastes bytes for every visitor. Load the active one on demand:
import { nextTick } from 'vue'
const loaded = new Set(['en'])
export async function setLocale(locale: string) {
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.lang = locale
document.documentElement.dir = ['ar', 'he', 'fa', 'ur'].includes(locale) ? 'rtl' : 'ltr'
localStorage.setItem('locale', locale)
await nextTick()
}
Vite turns the template-literal import() into one chunk per file in locales/ (a
pattern from Level 3 · 05). Call setLocale from a language switcher, or from a router
guard if the locale is part of the URL (/de/products), which is better for SEO and for
sharing links. We didn't run this function in a browser here; its parts —
setLocaleMessage, the locale ref, dynamic imports — are the documented vue-i18n and Vite
APIs.
Pick the initial locale from, in order: the URL, a saved preference, then
navigator.languages matched against the locales you actually have — and never only
from IP geolocation.
Right-to-left and layout¶
Setting dir="rtl" on <html> flips text direction, but only layouts written with
logical CSS properties flip with it:
/* breaks in RTL */
.card { margin-left: 1rem; text-align: left; }
/* follows the writing direction */
.card { margin-inline-start: 1rem; text-align: start; }
Flexbox and grid already follow the direction. Icons that imply direction (arrows, "back") need mirroring; logos and media controls usually don't. Also leave room: translated strings are often considerably longer than English, so test with a long-string pseudo-locale.
Safety: parameters are not escaped by default¶
t('hi', { name: '<img src=x onerror=alert(1)>' })
// default: Hello, <img src=x onerror=alert(1)>!
// escapeParameter: true: Hello, <img src=x onerror=alert(1)>!
(both observed). In a normal {{ t(...) }} interpolation that's harmless, because Vue
escapes text. It becomes an XSS hole the moment the result is passed to v-html —
typically when a translation contains markup like <strong>. Prefer vue-i18n's
<i18n-t> component, which interpolates components into slots of a message instead
of HTML, and if you must use v-html, enable escapeParameter (Level 3 · 09 covers
v-html risks).
How It Actually Works¶
- Messages compile to functions. vue-i18n parses each message string (
Hello, {name}!) into an AST and then into a function that concatenates static parts and resolved parameters. With the bundler plugin (@intlify/unplugin-vue-i18n), that compilation happens at build time, so the runtime doesn't ship the message compiler and works under a strict Content Security Policy. tis reactive because the locale is a ref.i18n.global.localeis a ref;t()reads it, so any render effect that calledt()tracks it (Level 3 · 01). Changing the locale re-runs exactly the components that displayed translated text.- Formatting is the platform's job.
n()andd()look up your named format and constructIntl.NumberFormat/Intl.DateTimeFormatwith the current locale. The CLDR data behind separators, currency placement and month names lives in the JavaScript engine — which is also why output can differ slightly between engine versions. - Fallback is a chain. A missing key walks the fallback locales (
de-AT→de→en, as configured), emitting the warnings you saw, before finally returning the key itself.
Common mistakes¶
- Concatenating translated fragments (
t('you_have') + count + t('items')) — word order differs between languages. Translate whole sentences with parameters. - English-only plural logic (
count === 1 ? ... : ...). - Formatting numbers and dates by hand instead of
n(),d()orIntl. - Using English text as keys — any copy edit changes every locale file.
- Physical CSS properties (
left,margin-right) that break in RTL. - Forgetting
<html lang>— screen readers use it to choose pronunciation. - Translations with HTML rendered through
v-htmlwithout escaping parameters.
Exercise¶
- Add English and German to your Recipe Book: the header, the recipe count ("no recipes / one recipe / N recipes"), and each recipe's "added on" date.
- Add Polish with the
Intl.PluralRules-based rule and verify 0, 1, 2, 5, 12, 22 and 25. - Add a language switcher that lazy-loads locale files. In the build output, confirm each locale is a separate chunk, and check in the Network panel that switching loads it once.
- Write a script for CI that compares keys across
locales/*.jsonand fails if any locale is missing a key that exists inen.json. - Add an Arabic pseudo-locale (any text is fine), switch to it, and fix every layout issue caused by physical CSS properties.