Software localization
A Comprehensive Guide to Vue Localization
Arguably the most approachable among the big three UI frameworks, Evan You’s Vue seems an unlikely contender among the giant Meta’s React and Google’s Angular. Yet this brainchild of one man has seen adoption that matches Angular’s, thanks to its gentle learning curve, first-class dev experience, and production-ready features.
With its popularity, Vue has given birth to a rich ecosystem of plugins, extensions, and services. Vue app internationalization (i18n) — presumably the reason you’re here — sees the robust third-party Vue I18n plugin as the apparent go-to. In this hands-on guide, we’ll use Vue I18n to internationalize a little demo app, covering everything you need to get started with Vue localization. Let’s go.
✋ Heads up » This article covers Vue 3 localization. If you’re interested in Vue 2, check out Vue 2 Localization with Vue I18n: A Step-by-Step Guide.
🔗 Resource » We're using the Vue I18n library in this article. If you would rather use i18next, our Deep Dive: Vue Translation with vue-i18next might be useful to you.
Library versions used
We’ve used the following NPM packages in this article (versions in parentheses).
- Vue (3.2) — our UI framework
- Vue router (4.1) — the official Vue SPA router
- Vue I18n (9.2) — Vue’s third-party go-to i18n library
- Tailwind CSS (3.1) — used for styling and optional for our purposes
🗒 Note » To focus on the i18n, we won’t show any CSS styling in this article. You can find all styling code in the full code of our article on GitHub.
Our demo
Our humble demo, Mushahed, is based on data from the Open Notify space APIs.
Our demo app celebrates the world’s brave astronauts
Attributions
Shout outs to the following people and organizations for providing their assets for free.
- We’ve used the Satellite icon by Akriti Bhusal on The Noun Project for our demo brand icon.
- Cai Xuzhe’s photo and Chen Dong’s photo are copyrighted by China News Service and used under the CC BY 3.0 license.
- Liu Yang’s photo is copyrighted by Manfred Werner (Tsui) and used under the CC BY-SA 3.0 license.
- All other astronaut photos are public domain.
Our demo is a Vue SPA spun up with npm init vue@latest
. We added router support and opted out of TypeScript when the project was being scaffolded. After the requisite gutting of the boilerplate components added by the scaffolding tool, we built this little hierarchy:
. └── src/ ├── components/ │ ├── AstroCard.vue │ ├── Astronauts.vue │ ├── Coords.vue │ ├── Footer.vue │ └── Nav.vue ├── router/ │ └── index.js ├── views/ │ ├── HomeView.vue │ └── AboutView.vue └── App.vue
🗒 Note » Our App
houses a Vue <RouterView>
and we’re using <RouterLink>
s in our nav. We’ll look at routing a bit later.
Let’s take a closer look at our <Astronauts>
component.
<script> import AstroCard from './AstroCard.vue' export default { components: { AstroCard }, data() { return { loading: true, astros: [], } }, created() { fetch('/data/astronauts.json') .then((res) => res.json()) .then((data) => { this.astros = data this.loading = false }) }, } </script> <template> <!-- No i18n: Hard-coded English --> <p v-if="loading">Loading...</p> <div v-else> <div> <h2> <!-- No i18n: Hard-coded plural --> 🧑🚀 {{ astros.length }} people in space </h2> <p> <!-- No i18n: Hard-coded date --> Updated Jul 26, 2022 </p> </div> <div> <AstroCard v-for="astro in astros" :key="astro.id" :name="astro.name" :nationality="astro.nationality" :craft="astro.craft" :photoUrl="astro.photoUrl" /> </div> </div> </template>
When <Astronauts>
is created, we load our astronaut data from public/data/astronauts.json
and feed it to instances of the presentational <AstroCard>
.
[ // ... { "id": 7, "name": "Jessica Watkins", "photoUrl": "j-watkins.jpg", "nationality": "USA 🇺🇸", "craft": "ISS" }, // ... ]
Note that our UI strings are all hard-coded in English at this point. Let’s take care of this and localize our app.
🔗 Resource » We’re omitting much of our demo starter code for brevity. You can get all of it from the start-options branch of our GitHub repo.
How do I install and set up Vue I18n?
This will shock you: We start with an NPM install on the command line from the root of our Vue project.
$ npm install vue-i18n@9
🗒 Note » You’ll want v9+ of Vue I18n if you’re working with Vue 3. Vue 2 uses Vue i18n v8.
🔗 Resource » Check out all the ways to install Vue I18n in the official documentation.
Once NPM has done its thing, we need to create a Vue I18n instance, configure it, and register it as a plugin with our Vue instance. Let’s construct the Vue I18n instance in a new module. We’ll create a directory called src/i18n
and place an index.js
file within.
import { createI18n } from 'vue-i18n' const i18n = createI18n({ // default locale locale: 'en', // translations messages: { en: { appTitle: 'Mushahed', }, ar: { appTitle: 'مشاهد', }, }, }) export default i18n
We pass our translation messages
to the i18n object that we construct with createI18n()
. The initial locale, the one our app defaults to on first load, is set via the locale
config option.
🗒 Note » I’m supporting English (en
) and Arabic (ar
) in my app. Feel free to support any languages you want here. Use a standard BCP 47 language tag (like en
) or a language tag with a region subtag (like en-US
) to identify your translation locales.
🔗 Resource » All config options for createI18n()
are available in the official API documentation.
Our Vue instance now needs to register our i18n
object as a plugin with a use()
call.
import { createApp } from 'vue' import App from './App.vue' import router from './router' import i18n from './i18n' import './assets/main.css' const app = createApp(App) app.use(i18n) app.use(router) app.mount('#app')
That should round out our setup. Let’s test our i18n by internationalizing the app title in our <Nav>
component.
<script setup> import { RouterLink } from 'vue-router' </script> <template> <nav> <img alt="Mushahed logo" src="@/assets/logo.svg" /> <!-- App title is hard-coded in English --> <span>Mushahed</span> <RouterLink to="/">Home</RouterLink> <RouterLink to="/about">About</RouterLink> </nav> </template>
We'll replace the hard-coded text with the following.
<script setup> import { RouterLink } from 'vue-router' </script> <template> <nav> <!-- ... --> <span>{{ $t('appTitle') }}</span> <!-- ... --> </nav> </template>
Available to all our components now is Vue I18n’s $t()
translation function. Calling $t('appTitle')
when the active locale is English (en
) will cause $t()
to return the message we specified at messages.en.appTitle
above. When the active locale is Arabic (ar
), messages.ar.appTitle
is returned.
🤿 Go deeper » Check out the myriad ways to use $t()
in the official API listing.
If we reload our app now we should see no change: That’s because our initial locale is configured to English. Let’s switch it to Arabic.
import { createI18n } from 'vue-i18n' const i18n = createI18n({ locale: 'ar', messages: { en: { appTitle: 'Mushahed', }, ar: { appTitle: 'مشاهد', }, }, }) export default i18n
Et voilà!
Our app name translated to Arabic
That’s all it takes to start working with Vue I18n in our apps. Of course, we probably want to keep adding translation messages as we grow our app. To keep things tidy, let’s refactor our messages
object to its own module.
export default { en: { appTitle: 'Mushahed', }, ar: { appTitle: 'مشاهد', }, }
import { createI18n } from 'vue-i18n' import messages from './messages' const i18n = createI18n({ locale: 'ar', messages, }) export default i18n
🗒 Note » When developing with Vue I18n, you might get a warning in your browser console that says, “You are running the esm-bundler build of vue-i18n…”. This is a known issue and may be fixed by the time you read this.
How do I translate messages in my components?
We touched on this when we tested our Vue I18n installation above, but it bears repeating. It takes two steps:
- In our
messages
object, under each of our locales, we add translations with a shared key. - We use
$t(key)
in our component templates to render the translation corresponding to the active locale.
Let’s apply this by localizing the rest of our <Nav>
component. We'll need a few more translations to start.
export default { en: { // Use same keys as ar appTitle: 'Mushahed', logo: 'Mushahed logo', home: 'Home', about: 'About', }, ar: { // Use same keys as en appTitle: 'مشاهد', logo: 'رمز مشاهد', home: 'الرئيسية', about: 'نبذة عنا', }, }
<script setup> import { RouterLink } from 'vue-router' </script> <template> <nav> <img :alt="$t('logo')" src="@/assets/logo.svg" /> <span>{{ $t('appTitle') }}</span> <RouterLink to="/">{{ $t('home') }}</RouterLink> <RouterLink to="/about">{{ $t('about') }}</RouterLink> </nav> </template>
We can use $t()
with the {{ }}
syntax to translate the inner text of an element. When translating an attribute, the :attribute
binding shorthand comes in handy.
Our navigation component when Arabic is the active locale
How do I work with dynamic values in my translation messages?
A common use case, interpolating values that change at runtime in our messages is easy with Vue I18n. Our <Coords>
component, which shows the coordinates of the International Space Station (ISS) at a given time, is a perfect place to demonstrate.
<script> export default { data() { return { coords: { latitude: 49.5908, longitude: 122.8927, }, datetime: new Date(1658828129000), } }, } </script> <template> <p> The ISS was over {{ coords.latitude }}° N, {{ coords.longitude }}° E on {{ datetime }} </p> </template>
We’ve hard-coded the coordinate and datetime values above for clarity, but in a real app these would likely be fetched from an API and updated on component created
. Vue I18n accommodates these dynamic values in its messages via a {placeholder}
syntax.
export default { en: { // ... issPosition: 'The ISS was over {latitude}° N, {longitude}° E on {datetime}', }, ar: { // ... issPosition: 'كانت محطة الفضاء الدولية فوق {latitude} درجة شمالا و {longitude} درجة شرقا يوم {datetime}', }, }
<script> export default { data() { return { coords: { latitude: 49.5908, longitude: 122.8927, }, datetime: new Date(1658828129000), } }, } </script> <template> <p> {{ $t('issPosition', { latitude: coords.latitude, longitude: coords.longitude, datetime, }) }} </p> </template>
Passing a second argument to $t()
— a map of key/value pairs where the keys match the ones in our translation messages — renders these messages with the injected values.
An Arabic message with interpolated dynamic values
🗒 Note » The numbers and date above are not in Arabic. We’ll take care of this a bit later.
🤿 Go deeper » Learn all the ways you can interpolate in messages from the official Vue I18n documentation.
How do I translate strings in my component JavaScript?
The $t()
function is available to our component JavaScript via this.$t()
. Let’s use this to refactor our <Coords>
component and move that chunky $t()
call to our component <script>
.
<script> export default { data() { return { coords: { latitude: 49.5908, longitude: 122.8927, }, datetime: new Date(1658828129000), } }, computed: { issPosition() { return this.$t('issPosition', { latitude: this.coords.latitude, longitude: this.coords.longitude, datetime: this.datetime, }) }, }, } </script> <template> <p>{{ issPosition }}</p> </template>
How do I work with HTML within my translation messages?
On occasion, we will need to place HTML inside our translation messages. Our <Footer>
is a good example.
<template> <p> Created with <a href="https://vuejs.org/">Vue</a> for a <a href="https://phrase.com/blog">Phrase blog</a> tutorial. </p> </template>
Localizing this string is tricky because the locations of the embedded links can differ depending on the translation language. We could just place the <a>
tags directly in our translation messages and exploit Vue’s unsafe v-html
directive to output the translations. This would expose us to XSS attacks if we’re not careful, however.
Vue I18n offers a better solution: Its <i18n-t>
component allows us to render its children, including HTML elements, inside our messages via placeholders.
export default { en: { // ... footer: 'Created with {0} for a {1}.', vue: 'Vue', phraseBlogTutorial: 'Phrase blog tutorial', }, ar: { // ... footer: '.تم إنشائه بواسطة {0} ليصاحب {1}', vue: 'ڤيو', phraseBlogTutorial: 'درس على مدونة فريز', }, }
<script> export default { data() { return { vueUrl: 'https://vuejs.org/', phraseBlogUrl: 'https://phrase.com/blog', } }, } </script> <template> <i18n-t keypath="footer" tag="p" scope="global"> <a :href="vueUrl">{{ $t('vue') }}</a> <a :href="phraseBlogUrl">{{ $t('phraseBlogTutorial') }}</a> </i18n-t> </template>
We pass <i18n-t>
a keypath
prop with the key of our parent translation message, footer
in this case. For rendering, we let <i18n-t>
know that we want it to output a surrounding <p>
via the tag
prop.
Within our parent message, we specify placeholders using list interpolation, meaning we index our placeholders starting with {0}
and moving on to {1}
, etc. Order matters here: The first <a>
inside <i18n-t>
will replace the {0}
placeholder, the second will replace {1}
, and so on. This allows us to control the order our HTML elements appear in each language’s translation message.
🗒 Note » If we don’t explicitly set the scope="global"
prop on the <i18n-t>
component we will get a console warning reading, “[intlify] Not found parent scope. use the global scope.”
🔗 Resource » The Component Interpolation section of the official guide covers the <i18n-t>
component in further detail.
How do I work with plurals in my translations?
The two English plural forms are simple: “a satellite is orbiting above”; “three satellites are orbiting above”. Other languages are more complex. Some have four plural forms. Welsh and Arabic have six. Vue I18n handles simple plurals, like those of English, out of the box. We can extend the plugin to handle complex plurals. We’ll cover both simple and complex plurals here.
🔗 Resource » The CLDR Language Plural Rules reference is canon for languages’ plural forms.
Let’s revisit the header of our <Astronauts>
component for a moment.
An astronaut counter
<script> // We populate the astros array here... </script> <template> <div> <div> <h2>🧑🚀 {{ astros.length }} people in space</h2> <p>Updated Jul 26, 2022</p> </div> <!-- ... --> </div> </template>
Our astronaut counter is hard-coded and ripe for localization. Let’s add an English message for it.
export default { en: { // ... peopleInSpace: '{n} person in space | {n} people in space', }, ar: { // ... } }
Vue I18n expects plural forms to be separated by a pipe (|
) character. We’ve specified the two plural forms for English above. The {n}
placeholder will be replaced by an integer counter when we retrieve the plural message.
<script> // We populate the astros array here... </script> <template> <div> <div> <h2>🧑🚀 {{ $tc('peopleInSpace', astros.length) }}</h2> <p>Updated Jul 26, 2022</p> </div> <!-- ... --> </div> </template>
$tc()
, another translation function injected by Vue i18n into all of our components, chooses the correct plural form based on its second parameter, the integer counter.
Renders of English plural forms. Note that {n} is replaced with our counter.
Two forms work fine for English, but our Arabic translation will need six plural variants.
export default { en: { // ... }, ar: { // ... peopleInSpace: 'لا يوجد أحد في الفضاء | يوجد شخص {n} في الفضاء | يوجد شخصان في الفضاء | توجد {n} أشخاص في الفضاء | يوجد {n} شخص في الفضاء | يوجد {n} شخص في الفضاء', }, }
On its own Vue I18n only works with English-like plurals, so we need to add a custom extension function that handles Arabic’s six forms.
export function arabicPluralRules(choice) { const name = new Intl.PluralRules('ar').select(choice) return { zero: 0, one: 1, two: 2, few: 3, many: 4, other: 5 }[name] }
JavaScript’s standard Intl.PluralRules object handles complex plurals wonderfully. All we have to do is give it a locale when constructing it, then call its select()
method with an integer counter. The method returns the name of the correct form for the given language. For example, new Intl.PluralRules('ar').select(5)
returns the correct few
form.
Vue I18n needs an integer index to select the correct form in our translation messages, so our custom plural selector needs to map the CLDR plural form name (few
) to a zero-based index (3
). The index selects from our pipe-separated message. So 3
would select our fourth variant from the peopleInSpace
message above.
All we have to do now is wire up our Arabic plural rule selector when constructing the Vue I18n instance.
import { createI18n } from 'vue-i18n' import messages from './messages' import { arabicPluralRules } from './plurals' const i18n = createI18n({ locale: 'ar', messages, // Vue I18n allows us to extend its plural // formatting by providing one form selector // function per locale pluralizationRules: { ar: arabicPluralRules, }, }) export default i18n
With our selector wired up, our Arabic plurals should work like a charm.
Renders of Arabic plural forms. Note that {n} is replaced with our counter.
✋ Heads up » You may have noticed that the interpolated counter is being displayed in Western Arabic numerals (1, 2, etc.). However, Arabic uses Eastern Arabic numerals (١، ٢، ٣، etc.). While not a showstopper, I have logged this issue on the Vue i18n GitHub if you care to follow it.
🔗 Resource » Learn more from the official guide on pluralization.
How do I format localized numbers?
Different locales use different numeral systems, thousands separators, and symbols when representing numbers. JavaScript’s built-in Intl.NumberFormat object handles all this for us and is used under the hood by Vue I18n. We just need to give Vue I18n preconfigured number formats, which the plugin in turn passes to Intl.NumberFormat
. The formats we registered are then available to use in our components.
🤿 Go deeper » Our Concise Guide to Number Localization covers numeral systems, separators, and more.
// We specify the formats our app will use export const numberFormats = { 'en-US': { // A named format coords: { // These options are passed to Intl.NumberFormat style: 'decimal', minimumSignificantDigits: 6, maximumSignificantDigits: 6, }, }, 'ar-EG': { coords: { style: 'decimal', minimumSignificantDigits: 6, maximumSignificantDigits: 6, }, }, }
We need to register our number formats with the Vue I18n object during construction.
import { createI18n } from 'vue-i18n' import messages from './messages' import { numberFormats } from './numbers' import { arabicPluralRules } from './plurals' const i18n = createI18n({ locale: 'en-US', messages, numberFormats, pluralizationRules: { 'ar-EG': arabicPluralRules, }, }) export default i18n
Now we can use the injected $n()
function to format localized numbers in our component templates.
<!-- We specify the named format as the second param --> <p>{{ $n(49.5908, 'coords') }}</p> <!-- => "49.5908" when locale is en-US --> <!-- => "٤٩،٥٩٠٨" when locale is ar-EG -->
✋ Heads up » You may have noticed that we swapped en
with en-US
and ar
with ar-EG
in our configuration above. This is because number formatting is region-specific, not language-specific. Adding countries or regions to our locale tags means we can control the output of localized number formatting. Otherwise, we risk the browser using a default region. Of course, we have to update our messages
so that they’re keyed with en-US
and ar-EG
as well.
export default { 'en-US': { appTitle: 'Mushahed', // ... }, 'ar-EG': { appTitle: 'مشاهد', // ... }, }
Let’s update our <Coords>
component to format the ISS coordinates in the active locale’s number format.
<script> export default { data() { return { coords: null, datetime: '', } }, created() { // We fetch the coordinate data and set // this.coords and this.datetime here... }, computed: { issPosition() { return this.$t('issPosition', { latitude: this.$n(this.coords.latitude, 'coords'), longitude: this.$n(this.coords.longitude, 'coords'), datetime: this.datetime, }) }, }, } </script> <template> <p>{{ issPosition }}</p> </template>
Renders of Arabic plural forms. Note that {n} is replaced with our counter.
🔗 Resource » Get more details from the Number Formatting section of the Vue I18n documentation.
The date above looks very English in the otherwise Arabic message, doesn’t it? No worries. Guess what’s next?
How do I format localized dates and times?
Much like number formatting, date formatting is region-specific. The US and Canada both use English, but the 9th of September, 2022 can be 9/4/2022 in the US and 2022-09-04 in Canada. To work with localized dates correctly, we follow a recipe much like we did with dates. We provide Vue I18n with named datetime formats, which the plugin passes as options to the standard Intl.DateTimeFormat. We then use these registered formats in our components.
🗒 Note » Date localization is very similar to number localization, so this section builds on the last.
export const datetimeFormats = { 'en-US': { full: { // These options are passed to Intl.DateTimeFormat dateStyle: 'full', timeStyle: 'full', }, short: { year: 'numeric', month: 'short', day: 'numeric', }, }, 'ar-EG': { full: { dateStyle: 'full', timeStyle: 'full', }, short: { year: 'numeric', month: 'long', day: 'numeric', }, }, }
import { createI18n } from 'vue-i18n' import messages from './messages' import { numberFormats } from './numbers' import { datetimeFormats } from './datetimes' import { arabicPluralRules } from './plurals' const i18n = createI18n({ locale: 'en-US', messages, numberFormats, datetimeFormats, pluralizationRules: { 'ar-EG': arabicPluralRules, }, }) export default i18n
With formats specified and registered, we can use the injected $d()
function to display localized dates in our components. Let’s round out our <Coords>
component with proper date formatting.
<script> export default { // ... computed: { issPosition() { return this.$t('issPosition', { latitude: this.$n(this.coords.latitude, 'coords'), longitude: this.$n(this.coords.longitude, 'coords'), datetime: this.$d(this.datetime, 'full'), }) }, }, } </script> <template> <p>{{ issPosition }}</p> </template>
American English and Egyptian Arabic full date formats
While we’re at it, let’s format our <Astronauts>
header to show localized short dates in its “Updated” message.
export default { 'en-US': { // ... updatedAt: 'Updated {date}', }, 'ar-EG': { // ... updatedAt: 'أخر تحديث {date}', }, }
<script> // ... </script> <template> <!-- ... --> <h2>🧑🚀 {{ $tc('peopleInSpace', astros.length) }}</h2> <p> {{ $t('updatedAt', { date: $d(updated, 'short') }) }} </p> </template>
American and Egyptian short dates rendered
🔗 Resource » The official Vue I18n guide covers more date formatting options.
How do I retrieve the active locale?
Sometimes we need to make decisions based on the runtime locale of the app. With Vue I18n we can get the active locale simply via i18n.locale
.
<script> export default { methods: { activeLocale() { return this.$i18n.locale } } } </script> <template> <!-- Assuming active locale is en-US --> <p>{{ $i18n.locale}}</p> <!-- => <p>en-US</p> --> <p>{{ activeLocale() }}</p> <!-- => <p>en-US</p> --> </template>
We can also assign a new value to $i18n.locale
to set a new active locale. We’ll see this in action momentarily.
Refactoring the i18n library
In the next few sections we’ll tackle some advanced topics like localized routes and asynchronous translation file loading. These will be easier to implement if we refactor our little i18n library so that we can control how locales are set and loaded.
import { createI18n } from 'vue-i18n' import { messages } from './messages' import { numberFormats } from './numbers' import { arabicPluralRules } from './plurals' import { datetimeFormats } from './datetimes' // Set and expose the default locale export const defaultLocale = 'en-US' // Private instance of VueI18n object let _i18n // Initializer function setup(options = { locale: defaultLocale }) { _i18n = createI18n({ locale: options.locale, fallbackLocale: defaultLocale, messages, numberFormats, datetimeFormats, pluralizationRules: { 'ar-EG': arabicPluralRules, }, }) setLocale(options.locale) return _i18n } // Sets the active locale. function setLocale(newLocale) { _i18n.global.locale = newLocale } // Public interface export default { // Expose the VueI18n instance via a getter get vueI18n() { return _i18n }, setup, setLocale, }
🗒️ Note » Vue I18n supports scoping which we can use to change the locale for a subset of our app’s component hierarchy. We use i18n.global
to access Vue I18n’s global, app-wide scope. This is Vue I18n's default scope, and we’ll only work with global scope in this article.
Let’s see how the rest of our app changes based on this refactor.
import { createApp } from 'vue' import App from './App.vue' import router from './router' import i18n from './i18n' import './assets/main.css' const app = createApp(App) // Explicitly initialize the i18n library i18n.setup() // Pass the VueI18n instance as a plugin to use() app.use(i18n.vueI18n) app.use(router) app.mount('#app')
Nothing else in our app needs to change, yet our refactor will allow us to build more complex features in the following sections more easily.
How do I localize my routes?
It’s often a good idea to make sure that our URLs reflect the associated content. Localized URLs can mean that /en-US/foo
and /ar-EG/foo
point to the English and Arabic versions of the foo
page, respectively. Let’s get this working in our demo app.
First, let’s take a look at how we’ve configured the routes in our demo.
import { createRouter, createWebHistory } from 'vue-router' import HomeView from '../views/HomeView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ { path: '/', name: 'home', component: HomeView }, { path: '/about', name: 'about', // Lazy-loaded via code-splitting component: () => import('../views/AboutView.vue') } ] }) export default router
Our relatively simple setup has the /
route loading our <HomeView>
and /about
loading our <AboutView>
. The components are loaded inside a <router-view>
in the root <App>
component.
<script setup> import { RouterView } from 'vue-router' import Nav from './components/Nav.vue' import Footer from './components/Footer.vue' </script> <template> <div> <header> <Nav /> </header> <RouterView /> <Footer /> </div> </template>
Let’s localize these routes so that /en-US/about
shows the English About page and /ar-EG/about
shows the Arabic About page.
import { createRouter, createWebHistory } from 'vue-router' import { defaultLocale } from '../i18n' import HomeView from '../views/HomeView.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [ // The root path always redirects to a // localized route { path: '/', redirect: `/${defaultLocale}`, }, // All paths under the root are localized { path: '/:locale', children: [ { // The empty path specifies the default // child route component path: '', component: HomeView, }, { // Using the relative 'about' not the absolute // '/about' allows us to include the :locale // param from the parent. path: 'about', component: () => import('../views/AboutView.vue'), }, ], }, ], }) export default router
🔗 Resource » The official Vue Router guide is a great start to learn the basics of Vue routing.
With these changes, if we now visit /
, we will be redirected to /en-US
(assuming en-US
is our configured default locale). /en-US
is our localized root route. It shows the <HomeView>
in the <App>
’s <router-view>
by default. /en-US/about
shows the <AboutView>
.
However, if we visit /ar-EG
, or any other Arabic route, we’re greeted with English translations. That’s because we’re not switching the active locale when the :locale
route parameter changes. Let’s remedy this using a beforeEach
router navigation guard.
import { createRouter, createWebHistory } from 'vue-router' import i18n, { defaultLocale } from '../i18n' // ... const router = createRouter({ // ... }) router.beforeEach((to, from) => { const newLocale = to.params.locale const prevLocale = from.params.locale // If the locale hasn't changed, do nothing if (newLocale === prevLocale) { return } i18n.setLocale(newLocale) }) export default router
The Vue router’s handy global beforeEach() guard runs before any navigation, ensuring that when the locale param in the URL changes we’ll know about it. We pass an anonymous callback to the guard, and use our new setLocale()
function to update Vue I18n’s active locale when the locale param changes. This means that when we hit /ar-EG/about
, we’ll see the Arabic version of the About page.
Our Arabic routes now show Arabic translations
How do I build a reusable localized link component?
One problem with our current localized routing solution is that we would need to inject the :locale
route parameter manually every time we create a router link.
<script setup> import { RouterLink } from 'vue-router' </script> <template> <nav> <!-- ... --> <router-link :to="`/${$i18n.locale}`"> {{ $t('home') }} </router-link> <router-link :to="`/${$i18n.locale}/about`"> {{ $t('about') }} </router-link> </nav> </template>
This doesn’t scale very well and is error-prone. Let’s DRY (Don’t Repeat Yourself) this up by wrapping Vue’s <router-link>
in a custom component that handles route localization automatically.
<script> import { RouterLink } from 'vue-router' export default { // Expose the to prop to accept relative, // non-localized URIs props: ['to'], components: { RouterLink }, computed: { localizedUrl() { // The root / route is special since it's // absolute return this.to === '/' ? `/${this.$i18n.locale}` : `/${this.$i18n.locale}/${this.to}` }, }, } </script> <template> <!-- Internally, we're just using Vue's good old router link --> <router-link :to="localizedUrl"> <slot></slot> </router-link> </template>
Our new <LocalizedLink>
is almost a drop-in replacement for <router-link>
s. We just need to be careful to use relative URLs for anything other than the root route.
<script setup> import { RouterLink } from 'vue-router' import LocalizedLink from './l10n/LocalizedLink.vue' </script> <template> <nav> <!-- ... --> <LocalizedLink to="/"> {{ $t('home') }} </LocalizedLink> <!-- When active locale is ar-EG, renders to /ar-EG --> <!-- Notice that we point to about not /about --> <LocalizedLink to="about"> {{ $t('about') }} </LocalizedLink> <!-- When active locale is ar-EG, renders to /ar-EG/about --> </nav> </template>
How do I build a language switcher UI?
To allow our site visitors the ability to select their locales, let’s build a language switcher dropdown component that makes use of our localized routes. First, we’ll configure and expose our app’s supported locales in our i18n library.
// ... // Using a { localeCode: localeData } structure // allows us to add metadata, like a name, to each // locale as our needs grow. export const supportedLocales = { 'en-US': { name: 'English' }, 'ar-EG': { name: 'العربية (Arabic)' }, } // ...
We can now import our supportedLocales
and use them in a new <LocaleSwitcher>
component, which wraps a humble <select>
.
<script> import { supportedLocales } from '../../i18n' export default { methods: { // Called when the user selects a new locale // from the dropdown onLocaleChange(event_) { const newLocale = event_.target.value // If the selected locale is the same as the // active one, do nothing if (newLocale === this.$i18n.locale) { return } // Navigate to the localized root route for // the chosen locale this.$router.push(`/${newLocale}`) }, }, computed: { // Transfrom our supportedLocales object to // an array of [{ code: 'en-US', name: 'English' }, ...] locales() { return Object.keys(supportedLocales).map((code) => ({ code, name: supportedLocales[code].name, })) }, }, } </script> <template> <select :value="$i18n.locale" @change="onLocaleChange($event)" > <option v-for="locale in locales" :key="locale.code" :value="locale.code" > {{ locale.name }} </option> </select> </template>
🤿 Go Deeper » We’re using $router.push()
in our <LocaleSwitcher>
to navigate to the chosen locale’s root route. Learn more about Vue Router’s programmatic navigation in the official guide.
Now we can drop our new component into our app’s navigation bar for our users.
<script setup> import LocalizedLink from './l10n/LocalizedLink.vue' import LocaleSwitcher from './l10n/LocaleSwitcher.vue' </script> <template> <div> <nav> <img :alt="$t('logo')" src="@/assets/logo.svg"/> <span class="font-bold text-purple-300">{{ $t('appTitle') }}</span> <LocalizedLink to="/">{{ $t('home') }}</LocalizedLink> <LocalizedLink to="about">{{ $t('about') }}</LocalizedLink> </nav> <LocaleSwitcher /> </div> </template>
Our language switcher component in action
Binding directly to i18n.locale
If you’re not using localized routes, you can bind directly to $i18n.locale
as follows.
<script> import { supportedLocales } from '../../i18n' export default { computed: { locales() { // ... }, }, } </script> <template> <!-- Using Vue's v-model for two-way binding --> <select v-model="$i18n.locale"> <option v-for="locale in locales" :key="locale.code" :value="locale.code" > {{ locale.name }} </option> </select> </template>
🤿 Go Deeper » You can learn more about locale changing in Vue I18n’s documentation.
How do I load my translation files asynchronously?
As our apps grow, and we add more supported locales, we risk bloating our main bundle with all our translation messages. Realistically we will only need messages for the current visitor’s chosen locale. We can make our main bundle leaner by downloading only the active locale’s messages when needed.
Let’s add this async translation loading to our demo app. We’ll start by breaking our messages.js
file up into per-locale JSON files.
{ "appTitle": "Mushahed", "home": "Home", "about": "About", // ... }
{ "appTitle": "مشاهد", "home": "الرئيسية", "about": "نبذة عنا", // ... }
Next we’ll add a loading function to our i18n library.
import { nextTick } from 'vue' // ... let _i18n // ... async function loadMessagesFor(locale) { const messages = await import( /* webpackChunkName: "locale-[request]" */ `../translations/${locale}.json` ) _i18n.global.setLocaleMessage(locale, messages.default) return nextTick() } export default { get vueI18n() { return _i18n }, setup, setLocale, loadMessagesFor, }
loadMessagesFor()
uses Webpack’s async code splitting and dynamic imports to asynchronously load the translation file for the given locale. Once the translation file has loaded, it feeds the file’s messages to Vue I18n, associating them with the given locale. Finally, to ensure that Vue has updated the DOM before we resolve, we return the Promise from nextTick().
Now we can update the beforeEach()
navigation guard in our router to load the locale’s messages before rendering a route’s associated component.
import { createRouter, createWebHistory } from 'vue-router' import i18n, { defaultLocale } from '../i18n' // ... const router = createRouter({ // ... }) // We make the callback function async... router.beforeEach(async (to, from) => { const newLocale = to.params.locale const prevLocale = from.params.locale if (newLocale === prevLocale) { return } // ...so we can wait for the messages to load // before we continue await i18n.loadMessagesFor(newLocale) i18n.setLocale(newLocale) }) export default router
If we reload our app now we shouldn’t see any major changes. However, if we open the network tab in our browser’s developer tools, we should see that a message JSON file loads in when we switch locales.
Using Webpack's code splitting to asynchronously load a locale's translations
🔗 Resource » Read more about async/lazy loading in Vue I18n’s Lazy loading guide.
How do I work with locale fallback?
You may have noticed some console warnings after implementing asynchronous translation loading above. The warnings occur when you load an en-US
route for the first time.
Vue I18n attempting to fall back on a more general locale when it can't find a translation message
What’s happening is that Vue I18n can’t find any en-US
message when the app first loads. The en-US
messages load in a HTTP request separate from the main bundle, so they may not be available when the app first loads. We’ll address this in a minute.
Notice, however, that Vue I18n tries to find the logo
message in a general en
locale when it can’t find it in the region-specific en-US
locale. This is the library’s default fallback behaviour. It can come in quite handy when one of our locales is missing translations.
🤿 Go Deeper » Check out all of the options Vue I18n gives you for fallback in the Fallbacking guide.
A fallbackLocale
config option is available to us as a bottom catch-all: Any locales we list under fallbackLocale
will be used to display a message if one can’t otherwise be found. Let’s use this option to ensure that we fall back on English in our app.
// ... import { createI18n } from 'vue-i18n' import { numberFormats } from './numbers' import { arabicPluralRules } from './plurals' import { datetimeFormats } from './datetimes' import defaultMessages from '../translations/en-US.json' export const defaultLocale = 'en-US' let _i18n function setup(options = { locale: defaultLocale }) { _i18n = createI18n({ locale: options.locale, fallbackLocale: defaultLocale, messages: { [defaultLocale]: defaultMessages }, numberFormats, datetimeFormats, pluralizationRules: { 'ar-EG': arabicPluralRules, }, }) setLocale(options.locale) return _i18n } // ...
We import
our en-US
translation messages and pass them into Vue I18n’s messages
option. This will include our English messages in the main bundle, ensuring that our app won’t have to wait for them to load asynchronously. Setting en-US
as the fallbackLocale
ensures that the equivalent English message will be shown instead of a missing message in another locale.
The English message for "home" is used instead of the missing Arabic message
Handy console warnings reveal Vue I18n's fallback chain
With that in place, our little demo app is internationalized.
Our internationalized demo app
🔗 Resource You can get all of the code of the internationalized Options API demo app we built above from GitHub. The demo code includes some features we didn’t have space for in this article, like listening for locale changes to reload data and right-to-left language support.
How do I localize my Vue app with the Composition API?
Everything we’ve covered in this article so far pertains to Vue’s object-oriented Options API. If your app is using the Composition API in Vue 3, we got you covered in this section.
✋ Heads up » Vue I18n is designed to work with either the Options API or Composition API, but not both. The Vue I18n Options API is the default and is called the Legacy API. Read the Migration to Composition API from Legacy API guide for information about limitations and caveats.
Before we refactor our I18n code, let’s briefly look at how we would refactor our Vue components (sans I18n) from the Options API to the Composition API.
🗒️ Note » The following sections build on what we’ve already covered in this article. If you’re new to Vue I18n it’s recommended that you read the rest of the article before continuing.
Only three files in our demo need to be refactored for Composition: AstroCard.vue
, Astronauts.vue
, and Coords.vue
.
<-- Use script setup syntactic sugar for single-file components --> <script setup> // Import functions from Vue import { computed } from 'vue' // Use macro to define `props` const props = defineProps({ name: String, photoUrl: String, nationality: String, craft: String, }) // Create computed property using computed() // and refactor prop reference to use `props.X` const fullPhotoUrl = computed(() => `/img/astros/${props.photoUrl}`) </script> <template> <!-- Nothing changes in the template --> </template>
<script setup> import { ref } from 'vue' import AstroCard from './AstroCard.vue' // Use ref to define reactive data const loading = ref(true) const astros = ref([]) // Logic that would run in created() is // written directly at the top level fetch('/data/astronauts.json') .then((res) => res.json()) .then((data) => { // Remember to use .value when getting/setting // values defined with ref() astros.value = data loading.value = false }) </script> <template> <!-- Nothing changes in the template --> </template>
🔗 Resource » We’ll stop here to keep things brief and get to the i18n. You can view the diff for the Composition API refactor (before i18n) on our GitHub repo.
Refactoring i18n to use the Composition API
There’s not too much to change if we want to use Vue I18n’s Composition API. Here’s what we’ll cover:
- Setting
legacy: false
when creating the VueI18n instance. - Refactoring
vueI18n.global.locale
tovueI18n.global.locale.value
. - Refactoring
tc()
calls tot()
for plurals. - Refactoring all
this.X
calls to their functional equivalents in our component<script>
s.
Let’s get to it.
Turning off legacy mode
By default Vue I18n is in “legacy” mode, where createI18n()
returns a VueI8n
object instance. We want the factory function to create a Composer instance, which provides functions like t()
and n()
to our composition component.
To accomplish this, we just need to pass one option to createI18n()
, setting legacy: false
.
// ... import { createI18n } from 'vue-i18n' export const defaultLocale = 'en-US' // ... let _i18n function setup(options = { locale: defaultLocale }) { _i18n = createI18n({ legacy: false, // Nothing else changes in our options }) setLocale(options.locale) return _i18n } // ...
Using reactive properties
As soon as we start using the Composition with legacy: false
we need to refactor our calls to Vue I18n’s locale
, since locale
now acts like a reactive ref.
// ... function setLocale(newLocale) { // Just like any Vue reactive ref, we have // to get/set it with .value _i18n.global.locale.value = newLocale setDocumentAttributesFor(newLocale) } // ...
That’s all we we have to change in our i18n library. The remaining updates will be in our components.
🔗 Resource » Check out the Composition API guide for additional info.
Using t() instead of tc() for plural messages
Vue I18n’s Composer instance doesn’t have a tc()
function for outputting plural messages; switching to the Composition API will cause Vue I18n to throw an error whenever we attempt to use tc()
. Instead, we can just the regular t()
function as a drop-in replacement.
<script setup> // ... </script> <template> <!-- ... --> <div> <div> <h2> <!-- Use $t() instead of $tc(): works exactly the same --> 🧑🚀 {{ $t('peopleInSpace', astros.length) }} </h2> <p> {{ $t('updatedAt', { date: $d(updated, 'short') }) }} </p> </div> <!-- ... --> </div> </div> </template>
🗒️ Note » $t()
, $d()
, and $n()
work in component <template>
s in both Legacy and Composition modes. This is because, by default, Vue I18n injects them globally in both modes. This is not the case with component <scripts>
, where $t()
, $d()
etc. are not available in Composition mode. We’ll deal with that next.
Using localization functions in component scripts
In our Coords
component we’re using this.$t()
, this.$d()
and this.$n()
to retrieve translation messages and localized dates and numbers, respectively.
<script> export default { data() { return { loading: true, coords: null, datetime: '', } }, created() { // We fetch coordinate data from the network here... }, computed: { issPosition() { const { latitude, longitude } = this.coords return this.$t('issPosition', { latitude: this.$n(latitude, 'coords'), longitude: this.$n(longitude, 'coords'), datetime: this.$d(this.datetime, 'full'), }) }, }, } </script> <template> <!-- Display coordinate data --> </template>
When we switch the Composition API, we no longer have this
pointing to the component instance, so we can’t use this.$t()
and its ilk anymore. Vue I18n provides a useI18n()
function that returns the Composer instance, which includes t()
and company. Let’s see it in action.
<script setup> import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' // Destructure functions from returned Composer instance const { t, n, d } = useI18n() const loading = ref(true) const coords = ref(null) const datetime = ref('') // We fetch coordinate data from the network here... const issPosition = computed(() => { const { latitude, longitude } = coords.value // Use functions without `this` return t('issPosition', { latitude: n(latitude, 'coords'), longitude: n(longitude, 'coords'), datetime: d(datetime.value, 'full'), }) }) </script> <template> <!-- ... --> </template>
✋ Heads up » Don’t use the $ prefix with the functional variants in component <script>
s.
Using the reactive locale
property
Just like this.$t()
needed to be refactored, so to does this.$i18n.locale
. We can get and set the active locale
by destructuring it from the Composer instance in our components. Let’s refactor our LocaleSwitcher
and LocalizedLink
components to use the reactive locale
property.
<script setup> import { computed } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { supportedLocales } from '../../i18n' const router = useRouter() const { locale } = useI18n() function onLocaleChange(event_) { const newLocale = event_.target.value // Just like other reactive refs, we need to // use locale.value to get/set the active locale if (newLocale === locale.value) { return } router.push(`/${newLocale}`) } const locales = computed(() => Object.keys(supportedLocales).map((code) => ({ code, name: supportedLocales[code].name, })) ) </script> <template> <!-- Notice that $i18n.locale is still available in our component templates --> <select :value="$i18n.locale" @change="onLocaleChange($event)" > <option v-for="locale in locales" :key="locale.code" :value="locale.code"> {{ locale.name }} </option> </select> </template>
<script setup> import { computed } from 'vue' import { RouterLink } from 'vue-router' import { useI18n } from 'vue-i18n' const props = defineProps(['to']) const { locale } = useI18n() const localizedUrl = computed(() => props.to === '/' ? `/${locale.value}` : `/${locale.value}/${props.to}` ) </script> <template> <router-link :to="localizedUrl"> <slot></slot> </router-link> </template>
🔗 Resource » The Composer instance exposes other properties: Check out the API docs for a comprehensive listing.
And with that, our refactor is complete.
Our demo works exactly as it did in Legacy mode
🔗 Resource » You can get the complete code for the Composition i18n demo from GitHub.
🔗 Resource » If you're interested in general JavaScript i18n, including other UI and i18n libraries, you might enjoy our Ultimate Guide to JavaScript Localization.
That about does it for our Vue 3 i18n demo. We hope you enjoyed it and learned a few things along the way. If you’re looking to take your i18n game to the next level, check out Phrase. Phrase supports Vue I18n out of the box with its In-Context Editor, allowing your translators to update messages directly in your app. The fully-featured Phrase web console, with machine learning and smart suggestions, is a joy for translators to use. Once translations are ready, they can sync back to your project automatically — Phrase comes with a CLI and syncs with Bitbucket, GitHub, and GitLab. You set it and forget it, leaving you to focus on the code you love. Check out all the features Phrase has to offer, and give it a spin with a 14-day free trial.