Software localization

Vue 2 Localization with Vue I18n: A Step-by-Step Guide

Get an insight into Vue 2 localization and learn how to plug the Vue I18n library into your app to make it ready for global users.
Software localization blog category featured image | Phrase

It's always a pleasure to work with the Vue JavaScript framework. The elegance of its design, coupled with the robust first-party additions like Vue Router for SPA routing and Vuex for state management, make it a delight to use for building modern browser apps.

Of course, if you're here, you probably know this already. You might have an app built with Vue, and you might be wanting to reap the benefits of internationalizing and localizing your app to reach a wider, global market. Well, have no fear.

This tutorial will walk you through everything you need to dive into Vue localization with the extremely popular Vue I18n library. Vue I18n plugs into your Vue apps and provides you with translation file management, message formatting, date and number formatting, and more to boot. We'll fill in the gaps that the library leaves so that you can have a robust i18n cookbook for your Vue apps.

✋ Heads up » This article covers Vue 2 localization. If you’re interested in Vue 3, check out our guide to Vue localization.

🔗 Resource » We're using the Vue I18n library in this article. If you would rather use i18next, our tutorial on Vue Translation with vue-i18next might be useful to you.

🔗 Resource » Explore the possibilities other frameworks have to offer and learn everything you need to make your JS applications accessible to international users with our ultimate guide to JavaScript localization.

Library Versions

We'll be using the following libraries (with versions at the time of writing) to work through localizing our Vue app:

  • Vue vue (2.6.11)
  • Vue Router vue-router (3.1.3)
  • Vue I18n vue-i18n (8.15.3)
  • Vue CLI @vue/cli (4.1.2) — We use this to install all the libraries above, but you don't strictly have to

Our Demo App

During the course of this article, we'll build a localized, small Vue SPA. We'll call it International Gourmet Coffee, and it will be a mock ecommerce storefront specializing in gourmet coffee from around the world. Of course, the point is to showcase Vue I18n and localization, so we won't cover features like adding to a cart or checking out. We'll just have a couple of pages that demo what we need to get cooking quickly. International Gourmet Coffee demo app | Phrase

This beauty of an app is what we'll have at the end of this article

🔗 Resource » You can grab the code for the entire, completed app from GitHub.

Installation

We'll start off by installing the Vue CLI, which makes quick work of spinning up new Vue SPA projects.

npm install -g @vue/cli

With the CLI installed, we can use the global vue command to create our demo project.

vue create vue-i18n-demo

When asked for the preset by the Vue CLI, we'll select "Manually select features" and select the following ones. Vue CLI Manually select features | Phrase

The most important option is adding the Router. Everything else is optional here.

Installing Vue I18n

Ok, now let's install Vue I18n. If we spun up our project with the Vue CLI, installing Vue I18n is a breeze. We simply run one command from the command line.

vue add i18n

This command will install the Vue I18n CLI plugin and will ask us a few questions about our project so it can create some i18n boilerplate for us. Vue I18n CLI plugin questions | Phrase

We'll go with all the defaults

The CLI will create and update some files in our Vue project. Let's go through these changes.

.env

The default and fallback locales are added as environment variables to the .env file in our project.

VUE_APP_I18N_LOCALE=en

VUE_APP_I18N_FALLBACK_LOCALE=en

src/i18n.js

A new src/i18n.js file is added that registers Vue I18n as a plugin to our Vue instance via the Vue.use() function.

import Vue from "vue";

import VueI18n from "vue-i18n";

Vue.use(VueI18n);

function loadLocaleMessages() {

  const locales = require.context(

    "./locales",

    true,

    /[A-Za-z0-9-_,\s]+\.json$/i

  );

  const messages = {};

  locales.keys().forEach(key => {

    const matched = key.match(/([A-Za-z0-9-_]+)\./i);

    if (matched && matched.length > 1) {

      const locale = matched[1];

      messages[locale] = locales(key);

    }

  });

  return messages;

}

export default new VueI18n({

  locale: process.env.VUE_APP_I18N_LOCALE || "en",

  fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en",

  messages: loadLocaleMessages()

});

The new file also houses a function, loadLocaleMessages(), that scans the src/locales directory for JSON files, and loads them in as translations messages. For example, a file called "fr.json" will have its contents loaded as the French (fr) translation messages. Finally, i18n.js constructs the VueI8n instance we'll use in our app, and exports it.

src/locales/en.json

The Vue I18n CLI will also have placed our first translation message file, based on the options we selected during installation. Since we selected English (en) as our default locale, and locales as the directory to store our message files, the CLI will have created a src/locales/en.json file to contain our English translation messages.

{

  "message": "hello i18n !!"

}

src/main.js

Our entry src/main.js file has the VueI8n instance created and added to the Vue constructor.

import Vue from "vue";

import App from "./App.vue";

import router from "./router";

import store from "./store";

import i18n from "./i18n";

Vue.config.productionTip = false;

new Vue({

  router,

  store,

  i18n,

  render: h => h(App)

}).$mount("#app");

vue.config.js

The CLI will have added the i18n entry under pluginOptions in our vue.config.js file as well.

module.exports = {

  pluginOptions: {

    i18n: {

      locale: "en",

      fallbackLocale: "en",

      localeDir: "locales",

      enableInSFC: false

    }

  }

};

Of course, the vue-i18n package will also have been added to our package.json file.

🗒 Note » If you have an existing Vue project, and don't want to install the Vue CLI, you can recreate the automated installation above by adding and modifying the files that the Vue I18n CLI plugin does for you. You'll, of course, need to install the package manually via npm install --save vue-i18n. Here's a convenient one-stop shop PR for you to see all the file changes in one place.

Creating our Demo

With Vue and Vue I18n installed, let's build the demo app that we'll localize using Vue I18n. We'll add a simple navigation bar, some coffee data for our home page, and placeholder text in our about page.

<template>

  <div id="nav">

    <img alt="Vue logo" src="../assets/logo-circle-sm.png" />

    <router-link to="/">Home</router-link>

    <router-link to="/about">About</router-link>

  </div>

</template>

<style>

#nav {

  display: flex;

  align-items: center;

  text-align: left;

  padding: 1rem;

  color: #42b983;

  background-color: #3d536a;

}

#nav img {

  margin-right: 1rem;

}

#nav a {

  margin-right: 1.5rem;

  font-weight: bold;

  color: #fff;

  text-decoration: none;

}

</style>

Notice that the <router-link>s that Vue gives us as boilerplate are now in our own Nav component. The rest is visual flare.

<template>

  <div id="app">

    <Nav />

    <div class="container">

      <router-view />

    </div>

  </div>

</template>

<script>

import Nav from "@/components/Nav"

export default {

  components: { Nav }

}

</script>

<style>

body {

  margin: 0;

}

#app {

  font-family: "Avenir", Helvetica, Arial, sans-serif;

  -webkit-font-smoothing: antialiased;

  -moz-osx-font-smoothing: grayscale;

  color: #2c3e50;

}

#app .container {

  padding: 1rem;

}

</style>

Our new Nav component is pulled into our main App component. We've also wrapped the App's <router-view> in a #container for styling. Now let's take care of our home page content. We'll simulate a backend by adding a JSON file with our data.

[

  {

    "id": 1,

    "title": "Battlecreek Columbia Coldono",

    "imgUrl": "/img/battlecreek-coffee-roasters-_1wDmr4dtuk-unsplash.jpg",

    "addedOn": "2020-01-02"

  },

  {

    "id": 2,

    "title": "Battlecreek Yirgacheffee",

    "imgUrl": "/img/battlecreek-coffee-roasters-HvzR2yXtii4-unsplash.jpg",

    "addedOn": "2020-01-05"

  },

  {

    "id": 3,

    "title": "Primo Passo",

    "imgUrl": "/img/jon-tyson-KRedbshBxEk-unsplash.jpg",

    "addedOn": "2020-01-05"

  },

  {

    "id": 4,

    "title": "Little Nap Brazil",

    "imgUrl": "/img/lex-sirikiat-QouiCn7u6kw-unsplash.jpg",

    "addedOn": "2020-01-06"

  },

  {

    "id": 5,

    "title": "Little Nap Blend",

    "imgUrl": "/img/manki-kim-mv7kxYh5Rko-unsplash.jpg",

    "addedOn": "2020-01-07"

  },

  {

    "id": 6,

    "title": "French Truck Peru Cajamarca",

    "imgUrl": "/img/ryan-spaulding-_uncFvtOC-4-unsplash.jpg",

    "addedOn": "2020-01-08"

  }

]

Let's fetch this data in a Cards component and display it.

<template>

  <div class="cards">

    <Card v-bind="card" v-for="card in cards" :key="card.id" />

  </div>

</template>

<script>

import Card from "./Card"

export default {

  data: () => ({

    cards: []

  }),

  created() {

    fetch("/data.json")

      .then(response => response.json())

      .then(data => (this.cards = data))

  },

  components: { Card }

}

</script>

<style scoped>

.cards {

  display: flex;

  justify-content: space-between;

  flex-wrap: wrap;

}

</style>

We can use a presentational Card component to display each coffee item as a card.

<template>

  <div class="card">

    <h3>{{ title }}</h3>

    <img :src="imgUrl" />

    <p>Added {{ addedOn }}</p>

  </div>

</template>

<script>

export default {

  props: {

    id: Number,

    title: String,

    imgUrl: String,

    addedOn: String

  }

}

</script>

<style scoped>

.card {

  width: 30%;

  margin-bottom: 2rem;

  border: 1px solid #f2f2f2;

  border-radius: 4px;

  box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);

}

h3 {

  margin: 0;

  padding: 0.5em;

  text-align: center;

  white-space: nowrap;

  overflow: hidden;

  text-overflow: ellipsis;

}

img {

  width: 100%;

}

p {

  text-align: center;

  margin: 0;

  padding: 0.5em;

}

</style>

Now we just need to pull our Cards component into our Home view.

<template>

  <div class="home">

    <h1>International Gourmet Coffee</h1>

    <Cards />

  </div>

</template>

<script>

import Cards from "@/components/Cards.vue"

export default {

  name: "home",

  components: {

    Cards

  }

}

</script>

With these changes in place, we should have something that looks like the following: Demo App | Phrase

Our little demo is ready for i18n

We now have a testbed to work with, and we can use it to localize our app using Vue I18n.

🔗 Resource » If you want to skip all the setup and get the demo in its current state (before we localize it), check out this tagged commit on GitHub.

Getting the Active Locale

Retrieving the active locale from Vue I18n is pretty easy. How we retrieve it depends on whether we're in a Vue component or not.

Getting the Active Locale Inside a Vue Component

Let's mock a quick language switcher component in our demo app. We won't build out the language switching functionality just yet, but we'll show the currently active language in our new component.

<template>

  <div class="locale-switcher">🌐 {{$i18n.locale}}</div>

</template>

As a Vue plugin registered in our Vue instance, the VueI18n object is available to all our components via the $i18n variable in our templates. The same object is, of course, available in our component scripts via this.$i18n. You may remember that the Vue i18n CLI plugin setup the VueI8n object for us in the i18n.js file. One of the several properties that can be accessed on VueI8n is the locale property, a string that corresponds to the currently active locale.

🔗 Resource » Check out all of the VueI8n properties and methods in the official Vue I18n API documentation.

Let's add our LocaleSwitcher to our Nav component.

<template>

  <div class="nav">

    <div class="nav__start">

      <img alt="Vue logo" src="../assets/logo-circle-sm.png" />

      <router-link to="/">Home</router-link>

      <router-link to="/about">About</router-link>

    </div>

    <div class="nav__end">

      <LocaleSwitcher />

    </div>

  </div>

</template>

<script>

import LocaleSwitcher from "@/components/LocaleSwitcher"

export default {

  components: { LocaleSwitcher }

}

</script>

<style scoped>

.nav {

  display: flex;

  justify-content: space-between;

  align-items: center;

  text-align: left;

  padding: 1rem;

  color: #fff;

  background-color: #3d536a;

}

.nav__start,

.nav__end {

  display: flex;

  align-items: center;

}

.nav img {

  margin-right: 1rem;

}

.nav a {

  margin-right: 1.5rem;

  font-weight: bold;

  color: #fff;

  text-decoration: none;

}

</style>

With these changes in place, we should see something like the following in our browser. Active locale render in demo app | Phrase

Rendering the active locale

We'll build out the rest of the LocaleSwitcher soon. First, let's check out how we can access the Vue I18n instance outside of our components.

Getting the Active Locale Outside of Vue Components

Whenever we want to access the VueI18n object (i18n) outside of our Vue components, we just import the instance from our i18n.js file. This grants us access not just to the locale property, of course, but to all of the properties and methods of the VueI8n instance.

import i18n from "@/i18n"

console.log("Active locale: ", i18n.locale)

Manually Setting the Active Locale

The nice thing about VueI18n's locale property is that it's read/write and reactive. So if we set a new value to the property, the components in our app that are showing translated messages will re-render to show their messages in the newly set locale.

<script>

export default {

  methods: {

    setLocale(locale) {

      this.$i18n.locale = locale

    }

  }

}

</script>

Now that we know how to get and set the active locale, we can get our LocaleSwitcher component working.

Building a Locale/Language Switcher

Let's update our LocaleSwitcher so that it displays a drop-down for selecting the active locale.

<template>

  <div class="locale-switcher">

    <select v-model="$i18n.locale">

      <option value="en">English</option>

      <option value="ar">Arabic</option>

    </select>

  </div>

</template>

The v-model Vue directive creates a two-way binding between the <select>'s current value and the active Vue I18n locale: the <select> will both show the active locale as the currently selected one and update the active locale if the user selects a different one. To test our LocaleSwitcher, let's place some translated messages in our app by localizing our Nav component. First, we'll add the messages to our message files.

🗒 Note » We'll cover translation messages in more detail a bit later. For now, we just want a way to see if our locale actually changes.

{

  "nav": {

    "home": "Home",

    "about": "About"

  }

}
{

  "nav": {

    "home": "الرئيسية",

    "about": "نبذة عنا"

  }

}

With these messages in place, let's update our Nav component so that it uses them instead of hard-coded values.

<template>

  <div class="nav">

    <div class="nav__start">

      <img alt="Vue logo" src="../assets/logo-circle-sm.png" />

      <router-link to="/">{{ $t("nav.home") }}</router-link>

      <router-link to="/about">{{ $t("nav.about") }}</router-link>

    </div>

    <div class="nav__end">

      <LocaleSwitcher />

    </div>

  </div>

</template>

<script>

import LocaleSwitcher from "@/components/LocaleSwitcher"

export default {

  components: { LocaleSwitcher }

}

</script>

<style scoped>

/* ... */

</style>

We use the $t() function that Vue I18n provides to our components for outputting translation messages. More on $t() a bit later. With the Nav component localized and the LocaleSwitcher working, we can now see and select the active locale from the nav bar.

Locale Switcher in demo app | Phrase

Our components react and re-render when our locale changes

Supported Locales

Vue I18n doesn't have a strict notion of supported locales: a list of locales that we are expected to have translations for. We can add this list ourselves, however.

🗒 Note » The VueI18n does have a read-only property called availableLocales, which is an array of locale keys that have messages loaded into the VueI8n instance. availableLocales can be helpful, but is somewhat limited: it doesn't have the human-readable names of locales. Worse yet, availableLocales will be inaccurate if we use it as a way to determine supported locales if we lazy load these locales in our app. This is because the list will be empty when we first load our app, and before we load any message files. We cover lazy loading later in this article.

A simple config file with locale codes mapped to human-readable names can do the trick.

export default {

  en: "English",

  ar: "عربي (Arabic)"

}

We can also write a utility function that provides these values in a way that makes them easily consumable by our views.

import supportedLocales from "@/config/supported-locales"

export function getSupportedLocales() {

  let annotatedLocales = []

  for (const code of Object.keys(supportedLocales)) {

    annotatedLocales.push({

      code,

      name: supportedLocales[code]

    })

  }

  return annotatedLocales

}

getSupportedLocales() uses our configuration object to create an array of the form [{ code: "en", name: "English"}]. We can use this array in the LocaleSwitcher we built earlier so that we have a single source of truth for our supported locales.

<template>

  <div class="locale-switcher">

    <select v-model="$i18n.locale">

      <option :value="locale.code" v-for="locale in locales" :key="locale.code">

        {{locale.name}}

      </option>

    </select>

  </div>

</template>

<script>

import { getSupportedLocales } from "@/util/i18n/supported-locales"

export default {

  data: () => ({ locales: getSupportedLocales() })

}

</script>

Instead of hard-coding the locales in our switcher, we iterate over the supported locales and display each as an <option>. This is simply a refactor, and our LocaleSwitcher should behave in exactly the same way it did before. Another helpful feature is being able to check whether a given locale is in our list of supported locales. In fact, we'll want this feature in a moment when we retrieve the user's locale from the browser. As you can imagine, the logic for this check is trivial.

import supportedLocales from "@/config/supported-locales"

// ...

export function supportedLocalesInclude(locale) {

  return Object.keys(supportedLocales).includes(locale)

}

✋🏽 Heads Up » It might be tempting to use Webpack's require.context() to check the locales directory and infer the supported locales based on the message files there, e.g. if /src/locales/fr.json exists, then we assume that we support French. However, I've noticed that using require.context() this way can interfere with Webpack's async/lazy loading of translations, which we'll cover a bit later.

Detecting the User's Preferred Locale in the Browser

While not strictly related to Vue I18n per se, detecting the user's locale is often a good idea when it comes to providing a friendly UX. We can use the browser's navigator.languages array to try to detect the user's preferred locale.

export default function getBrowserLocale(options = {}) {

  const defaultOptions = { countryCodeOnly: false }

  const opt = { ...defaultOptions, ...options }

  const navigatorLocale =

    navigator.languages !== undefined

      ? navigator.languages[0]

      : navigator.language

  if (!navigatorLocale) {

    return undefined

  }

  const trimmedLocale = opt.countryCodeOnly

    ? navigatorLocale.trim().split(/-|_/)[0]

    : navigatorLocale.trim()

  return trimmedLocale

}

In getBrowserLocale(), we first check the global navigator.languages array to see if it has any entries. This array will contain the languages that the user has selected in her browser preferences, in priority order. Webpage language settings | Phrase

This user prefers to see pages in Canadian English, then in Egyptian Arabic

This array might be empty in the browser preferences, in which case it will be undefined when we try to access it via navigator.languages. If this happens, we fall back to the navigator.language property. In some browsers, navigator.language will be the first element in navigator.languages, and it will be an empty string if the array is undefined. In other browsers, navigator.language will be the language of the UI, which is not necessarily navigator.languages[0]. If we couldn't infer the user's preferred language from either navigator.languages or navigator.language, we return undefined. In normal cases, however, the navigator will usually give us a standard language tag like "fr-FR". When our app covers language variants, like French (France) and French (Canada), this can be exactly what we want. However, sometimes we're only interested in the language code. Our getBrowserLocale() provides a countryCodeOnly option that gives us exactly that.

// navigator.languages == ["ar-SA", "en-US"]

getBrowserLocale()                          // => "ar-SA"

getBrowserLocale({ countryCodeOnly: true }) // => "ar

We can use our new function to set the initial locale of our app based on our user's preferences.

import Vue from "vue"

import VueI18n from "vue-i18n"

import getBrowserLocale from "@/util/i18n/get-browser-locale"

import { supportedLocalesInclude } from "./util/i18n/supported-locales"

Vue.use(VueI18n)

//...

function getStartingLocale() {

  const browserLocale = getBrowserLocale({ countryCodeOnly: true })

  if (supportedLocalesInclude(browserLocale)) {

    return browserLocale

  } else {

    return process.env.VUE_APP_I18N_LOCALE || "en"

  }

}

export default new VueI18n({

  locale: getStartingLocale(),

  fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en",

  messages: loadLocaleMessages()

})

We retrieve the browser locale, check if our app supports it, and if it does we use it as our starting locale. If our app doesn't support the browser locale we fall back to the default locale we set in our .env file. Now, if the user has set a language that our app supports, we'll show the app in that language first.

Basic Translation Messages

We've already seen how to display basic, reactive translation messages via Vue I18n's t() function.

{

  "hello": "Hello There!"

}

// 👆🏽in en.json

{

  "hello": "Bonjour!"

}

// 👆🏽in fr.json

// 👇🏽 in Vue component

<h2> {{ $t("hello") }} </h2>

In the above example, the <h2> will contain "Hello There!" when the active locale is English, and "Bonjour!" when the active locale is French. If the active locale were to change via i18n.locale = newLocale, our Vue component would re-render and attempt to show the <h2> with newLocale's translation. In addition to the t() function, Vue I18n provides a Vue directive, v-t, that can be used to display translated messages. Let's use the directive to localize our app's header.

{

  "app": {

    "title": "International Gourmet Coffee"

  },

  "nav": {

    "home": "Home",

    "about": "About"

  }

}
{

  "app": {

    "title": "قهوة الذواقة الدولية"

  },

  "nav": {

    "home": "الرئيسية",

    "about": "نبذة عنا"

  }

}
<template>

  <div class="home">

    <h1 v-t="'app.title'" />

    <Cards />

  </div>

</template>

<script>

// ...

</script>

Notice that when using v-t above, we wrapped its value in two sets of quotes. This is to provide the literal string 'app.title' to the directive, which causes it to behave like t('app.title'). Also, note that whether using t() or v-t, we can access messages that are nested in our JSON files via dot (.) notation.

🔗 Resource » The t() function is versatile, and has a few variants. Check out the official API documentation to get more details. While you're there, you may want to take a look at the v-t documentation as well. You'll find the directive has a few tricks up its sleeve.

🔗 Resource » You can completely override the way messages are formatted with Vue I18n. Read the official documentation on custom formatting for more info.

Interpolation

We often want to inject dynamic values into our translation messages. For example, we may have a greeting in our nav bar that we show to a logged-in user.

{

  "user_greeting": "Hello, {name}"

}
{

  "user_greeting": "مرحبا {name}"

}

Vue I18n will replace the {name} placeholder if we give a t() object parameter that provides the name's value.

<template>

  <div class="nav">

    <!-- ... -->

    <div class="nav__end">

      <p class="user-greeting">

        {{ $t("user_greeting", { name: "Adam" }) }}

      </p>

      <LocaleSwitcher />

    </div>

  </div>

</template>

<script>

// ...

</script>

<style scoped>

/* ... */

</style>

Given the messages above, we get {name} replaced with "Adam". Demo app bar with name | Phrase

Hey there, Adam

🔗 Resource » Vue I18n also supports list formatting e.g. "Hello {0}". Check out the official documentation on formatting for more details.

Using HTML in Translation Messages

Sometimes it's much more convenient for translators to have HTML in translation messages, rather than breaking up translation messages into smaller parts. Let's add a footer to our app to explore this.

{

  "footer": "Built with Vue and Vue I18n<br />Powered by an excessive amount of coffee"

}
{

  "footer": "بنيت بواسطة Vue و Vue I18n<br />مدعوم من كمية قهوة مبالغ فيها"

}
<template>

  <p class="footer">{{ $t("footer") }}</p>

</template>

<style scoped>

.footer {

  text-align: center;

  margin-bottom: 1rem;

}

</style>

As it is, if we were to pull our new Footer component into our App root component, we would get less than desirable results. HTML code in text in demo app | Phrase

Our HTML is escaped, which isn't what we want

Now we could use Vue's generally unsafe v-html directive to solve this problem.

<template>

  <p class="footer" v-html="$t('footer')" />

</template>

This certainly works: our Footer will now render two lines in the browser instead of one. The issue here is that v-html opens up our app to XSS injection attacks. Of course, we would generally trust our translators not to inject malicious code into our app. Still, if we can avoid v-html entirely, we can sleep better at night. Enter: component interpolation. We can use Vue I18n's <i18n> component to avoid v-html. First, let's update our translation messages.

{

  "footer": "Built with Vue and Vue I18n{0}Powered by an excessive amount of coffee"

}
{

  "footer": "بنيت بواسطة Vue و Vue I18n{0}مدعوم من كمية قهوة مبالغ فيها"

}

Instead of having the <br /> directly in our message, we have a {0} placeholder. Now let's update our Footer to use the <i18n> component.

<template>

  <i18n path="footer" tag="p" class="footer">

    <br />

  </i18n>

</template>

The "footer" key corresponds to the one in our translation files and is provided as the path prop to <i18n>. We also give the component a tag prop, which instructs it to render our translated message in a <p> tag. Notice that we've included regular HTML, namely our <br />, within the opening and closing tags of the <i18n> component. We could place any HTML we like here, and Vue I18n will inject it where the {0} placeholder is in our message. With this in place, our footer renders with two lines, exactly like we want. HTML in demo app bug fixed | Phrase

No escape: We have HTML without exposing ourselves to XSS

🔗 Resource » Read more about component interpolation in Vue I18n's official documentation.

Plurals

Let's add a counter that shows the number of likes on each variety of coffee we have. First, we'll update our JSON data to provide the number of likes.

[

  {

    "id": 1,

    "title": "Battlecreek Columbia Coldono",

    "imgUrl": "/img/battlecreek-coffee-roasters-_1wDmr4dtuk-unsplash.jpg",

    "addedOn": "2020-01-02",

    "likes": 3

  },

  {

    "id": 2,

    "title": "Battlecreek Yirgacheffee",

    "imgUrl": "/img/battlecreek-coffee-roasters-HvzR2yXtii4-unsplash.jpg",

    "addedOn": "2020-01-05",

    "likes": 0

  },

  // ...

]

Now we can update our Card component to bring in the likes.

<template>

  <div class="card">

    <h3>{{ title }}</h3>

    <img :src="imgUrl" />

    <div class="card__footer">

      <p>Added {{ addedOn }}</p>

      <p>{{likes}} people ❤️ this</p>

    </div>

  </div>

</template>

<script>

export default {

  props: {

    id: Number,

    title: String,

    imgUrl: String,

    addedOn: String,

    likes: Number

  }

}

</script>

<style scoped>

.card {

  width: 30%;

  margin-bottom: 2rem;

  border: 1px solid #f2f2f2;

  border-radius: 4px;

  box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);

}

h3 {

  margin: 0;

  padding: 0.5em;

  text-align: center;

  white-space: nowrap;

  overflow: hidden;

  text-overflow: ellipsis;

}

img {

  width: 100%;

}

.card__footer {

  text-align: center;

  margin: 0;

  padding: 0.5em;

}

p {

  margin: 0;

  font-size: 14px;

}

p:first-child {

  margin-bottom: 0.5rem;

}

</style>

This works, but our likes message is currently hard-coded. We could use the $t() function to localize it as usual, but this won't take into account the various plural forms our message can have. In English, for example, we want "No people ♥️ this", "1 person ♥️ this", and "3 people ♥️ like this" to display depending on the number of likes. Vue I18n's $tc() function can help with displaying plurals. To use it, we need to adopt a special syntax in our pluralized message. Let's update our English message to see this.

{

  "card": {

    "likes": "Nobody ❤️ this yet 😕 | {n} person ❤️ this | {n} people ❤️ this"

  }

}

English has three plural forms: zero, one, and many. We write all three forms in our message, separated by the |. We also use the special {n} placeholder to display the number $tc() receives. We can now use the function to dynamically display the correct plural form.

<template>

  <div class="card">

    <h3>{{ title }}</h3>

    <img :src="imgUrl" />

    <div class="card__footer">

      <p>Added {{ addedOn }}</p>

      <p>{{$tc("card.likes", likes)}}</p>

    </div>

  </div>

</template>

<script>

// ...

</script>

<style scoped>

/* ... */

</style>

$tc() is similar to $t(), except it takes a second, number argument that determines how it chooses between the plural forms we provided in the respective message. With the above changes in place, we get dynamic plural output.

🗒 Note » We can use {count} instead of {n}. Vue I18n treats either of them as the special count characrer in plural messages.

Demo app with plurals | Phrase

So much ❤️

Notice that we haven't included our Arabic messages yet. That's because we have a bit of a problem with Arabic, and we'll need custom logic to solve it.

Custom Pluralization

Vue I18n works with languages that have three plural forms out of the box. However, some languages have different plural rules. Arabic, for example, has six plural forms. For these scenarios, we'll need to override VueI18n's getChoiceIndex() method.

let defaultChoiceIndex

export function setDefaultChoiceIndexGet(fn) {

  defaultChoiceIndex = fn

}

/**

 * @param choice {number} a choice index given by the input to

 *   $tc: `$tc('path.to.rule', choiceIndex)`

 * @param choicesLength {number} an overall amount of available choices

 * @returns a final choice index to select plural word by

 **/

export function getChoiceIndex(choice, choicesLength) {

  if (defaultChoiceIndex === undefined) {

    return choice

  }

  // this === VueI18n instance, so the locale property also exists here

  if (this.locale !== "ar") {

    return defaultChoiceIndex.apply(this, [choice, choicesLength])

  }

  if ([0, 1, 2].includes(choice)) {

    return choice

  }

  if (3 <= choice && choice <= 10) {

    return 3

  }

  if (11 <= choice && choice <= 99) {

    return 4

  }

  return 5

}

We create a little utility file for ourselves that houses two functions, setDefaultChoiceIndexGet() and getChoiceIndex(). We'll use getChoiceIndex() to override the VueI18n instance's method by the same name. getChoiceIndex() does the work of determining which plural form to choose in a given message. We will sometimes want to defer to VueI18n's original implementation of getChoiceIndex(), so we'll provide setDefaultChoiceIndexGet() so that calling code can provide a reference to that original function for us. getChoiceIndex() receives two arguments, choice: number and choicesLength: number and is expected to return a number that it determines to be the index of the plural form to use in a message. The choice parameter is the given value of n or count . The choicesLength is the number of choices in the plural message. For example, the message "foo | bar | man" would give a choiceLength of 3. In our code above, we've decided to keep things simple and ignore choiceLength, assuming that all Arabic plural messages will provide all 6 plural forms that are common to Arabic:

  • 0
  • 1
  • 2
  • 3-10
  • 11-99
  • 100+

Let's use this logic by wiring it up to VueI8n's prototype.

import Vue from "vue"

import VueI18n from "vue-i18n"

import getBrowserLocale from "@/util/i18n/get-browser-locale"

import { supportedLocalesInclude } from "./util/i18n/supported-locales"

import {

  getChoiceIndex,

  setDefaultChoiceIndexGet

} from "./util/i18n/choice-index-for-plural"

Vue.use(VueI18n)

// ...

setDefaultChoiceIndexGet(VueI18n.prototype.getChoiceIndex)

VueI18n.prototype.getChoiceIndex = getChoiceIndex

export default new VueI18n({

  locale: getStartingLocale(),

  fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en",

  messages: loadLocaleMessages()

})

We make sure to call setDefaultChoiceIndexGet() with the original getChoiceIndex() before overriding with our own. This is because our getChoiceIndex() logic defers to the original implementation for non-Arabic locales. With that in place, we now have proper Arabic pluralization.

{

  // ...

  "card": {

    "likes": "لا توجد ❤️ إلى الآن 😕 | شخص {n} ❤️ هذا | شخصان ❤️ هذا | {n} أشخاص ❤️ هذا | {n} شخص ❤️ هذا | {n} شخص ❤️ هذا "

  }

}

Demo app in Arabic | Phrase

Our Arabic plurals are taking their many, correct forms

Formatting Dates

As it is, our "Added on" dates for our products are displayed as they are in our JSON data. Bad Date format in demo app | Phrase

Not the prettiest date

Luckily, Vue I18n taps into the standard Intl.DateTimeFormat to allow for flexible date formatting. First, let's create a dateTimeFormats object to pass to the VueI8n constructor.

const dateTimeFormats = {

  en: {

    short: { year: "numeric", month: "short", day: "numeric" }

  },

  ar: {

    short: { year: "numeric", month: "long", day: "numeric" }

  }

}

export default dateTimeFormats

We key our object by our supported locale codes. Under each locale key we can have any number of formats we want. In the above code, we provide a "short" date format, which differs subtly across locales. Notice that the format is defined as an object: this object is passed as the options parameter to the Intl.DateTimeFormat constructor, which handles the formatting. Now we can pass this dateTimeFormats object to the Vue I18n constructor for it to take effect.

import Vue from "vue"

import VueI18n from "vue-i18n"

import getBrowserLocale from "@/util/i18n/get-browser-locale"

import { supportedLocalesInclude } from "./util/i18n/supported-locales"

import {

  getChoiceIndex,

  setDefaultChoiceIndexGet

} from "./util/i18n/choice-index-for-plural"

import dateTimeFormats from "@/locales/date-time-formats"

Vue.use(VueI18n)

// ...

export default new VueI18n({

  locale: getStartingLocale(),

  fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en",

  messages: loadLocaleMessages(),

  dateTimeFormats

})

With our wiring done, we can now use Vue I18n's $d() function to display the formatted date.

<template>

  <div class="card">

    <h3>{{ title }}</h3>

    <img :src="imgUrl" />

    <div class="card__footer">

      <p>

        {{ $t("card.added") }}

        {{ $d(new Date(addedOn), "short") }}

      </p>

      <p>{{$tc("card.likes", likes)}}</p>

    </div>

  </div>

</template>

<script>

// ...

</script>

<style scoped>

/* ... */

</style>

The d() function is another that the Vue I18n plugin provides to our components and is responsible for formatting dates. Note that the first parameter to d() must be a Date object, so we parse our addedOn string to a Date when we pass it to d(). Now our dates are much more presentable. Localized dates | Phrase

Our dates are now formatted per-locale and per our liking

Formatting Numbers

Much like it handles date formatting, Vue I18n delegates its general number formatting to the standard Intl.NumberFormat. And much like date formatting, we provide a numberFormats object to the VueI8n constructor to determine our localized number formats.

const numberFormats = {

  en: {

    currency: { style: "currency", currency: "USD" }

  },

  ar: {

    currency: { style: "currency", currency: "USD", currencyDisplay: "code" }

  }

}

export default numberFormats

We use our locale codes as keys. Below each key, we place as many formats as we like. Each format is an object that will be passed to the Intl.Numberformat constructor as an options parameter. In the above, we specify currency formats for each of our two supported locales. We assume our currency is US dollars, and ensure that the ISO code ("USD", not the "$" symbol) will be shown in Arabic. Let's wire up this object to our VueI18n constructor.

import Vue from "vue"

import VueI18n from "vue-i18n"

import getBrowserLocale from "@/util/i18n/get-browser-locale"

import { supportedLocalesInclude } from "./util/i18n/supported-locales"

import {

  getChoiceIndex,

  setDefaultChoiceIndexGet

} from "./util/i18n/choice-index-for-plural"

import dateTimeFormats from "@/locales/date-time-formats"

import numberFormats from "@/locales/number-formats"

Vue.use(VueI18n)

// ...

export default new VueI18n({

  locale: getStartingLocale(),

  fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en",

  messages: loadLocaleMessages(),

  dateTimeFormats,

  numberFormats

})

With the numberFormats option in place, we can now use our formats in our Vue components. Let's add a price to our data to demonstrate.

[

  {

    "id": 1,

    "title": "Battlecreek Columbia Coldono",

    "price": 29.99,

    "imgUrl": "/img/battlecreek-coffee-roasters-_1wDmr4dtuk-unsplash.jpg",

    "addedOn": "2020-01-02",

    "likes": 3

  },

  // ...

]

We can reference this price in our Card component and use Vue I18n's $n() to display it.

<template>

  <div class="card">

    <h3>{{ title }}</h3>

    <img :src="imgUrl" />

    <div class="card__footer">

      <div class="card__meta">

        <p class="price">{{$n(price, "currency")}}</p>

        <p>{{ $d(new Date(addedOn), "short") }}</p>

      </div>

      <p class="likes">{{$tc("card.likes", likes)}}</p>

    </div>

  </div>

</template>

<script>

export default {

  props: {

    id: Number,

    title: String,

    price: Number,

    imgUrl: String,

    addedOn: String,

    likes: Number

  }

}

</script>

<style scoped>

.card {

  width: 30%;

  margin-bottom: 2rem;

  border: 1px solid #f2f2f2;

  border-radius: 4px;

  box-shadow: 0 2px 2px rgba(0, 0, 0, 0.1);

}

h3 {

  margin: 0;

  padding: 0.5em;

  text-align: center;

  white-space: nowrap;

  overflow: hidden;

  text-overflow: ellipsis;

}

img {

  width: 100%;

}

.card__footer {

  text-align: center;

  margin: 0;

  padding: 0.5em;

}

.card__meta {

  display: flex;

  justify-content: space-between;

  align-items: baseline;

}

p {

  margin: 0;

  font-size: 14px;

}

.price {

  margin-left: 0.5rem;

  font-size: 1.25rem;

  font-weight: bold;

}

.likes {

  text-align: end;

  margin-top: 0.5rem;

}

</style>

A lot like the d() function, n() takes a number and the key of the format we specified as parameters. We now have prices formatted differently per locale. Demo app with localized price format | Phrase

Now accepting payments from multiple locales

Changing the Document Language & Layout Direction

Arabic, Hebrew, and other languages are written from right to left, and pages presented in those locales need to be laid out in that direction as well. To accomplish this, we can listen for locale changes and update the document.dir property in the DOM. While we're at it, let's update the lang property of our document to reflect the currently active locale.

export function setDocumentDirectionPerLocale(locale) {

  document.dir = locale === "ar" ? "rtl" : "ltr"

}

export function setDocumentLang(lang) {

  document.documentElement.lang = lang

}

We've added a helper function that takes a locale and sets the document direction. Another sets the lang attribute on the HTML element for correct meta. We can use these functions in our root App component.

<template>

  <div id="app">

    <Nav />

    <div class="container">

      <router-view />

    </div>

    <Footer />

  </div>

</template>

<script>

import Nav from "@/components/Nav"

import Footer from "@/components/Footer"

import {

  setDocumentDirectionPerLocale,

  setDocumentLang

} from "@/util/i18n/document"

export default {

  components: { Nav, Footer },

  mounted() {

    this.$watch(

      "$i18n.locale",

      (newLocale, oldLocale) => {

        if (newLocale === oldLocale) {

          return

        }

        setDocumentLang(newLocale)

        setDocumentDirectionPerLocale(newLocale)

      },

      { immediate: true }

    )

  }

}

</script>

<style>

/* ... */

</style>

When the App component mounts to the DOM, we start watching the active locale for changes.

🗒 Note » We use the imperative this.$watch, and not the watch property of the component, so that we can provide the immediate option. When true, the immediate property causes our watcher to fire once during setup with the current value of the active locale. Otherwise, our watcher won't fire on app start, and we won't be able to set our document properties on initialization.

With the logic above, we both initialize and sync our document language and direction to the active locale's direction at all times. Demo app with proper right to left Arabic implementation | Phrase

English is displaying in left-to-right, Arabic in right-to-left

Localizing the Document Title

In a localized SPA we might want to change the document title, which appears in browser tabs, depending on the active locale. We already have an "app.title" in our message files. We also have a watcher from the previous section that allows us to run logic when the active locale changes. Let's add a utility function and that helps wire these things up to localize our document title.

export function setDocumentDirectionPerLocale(locale) {

  document.dir = locale === "ar" ? "rtl" : "ltr"

}

export function setDocumentLang(lang) {

  document.documentElement.lang = lang

}

export function setDocumentTitle(newTitle) {

  document.title = newTitle

}

Now we can use our new function in our App component, much like we did with document language & layout direction.

<template>

  <div id="app">

    <Nav />

    <div class="container">

      <router-view />

    </div>

    <Footer />

  </div>

</template>

<script>

import Nav from "@/components/Nav"

import Footer from "@/components/Footer"

import {

  setDocumentDirectionPerLocale,

  setDocumentTitle,

  setDocumentLang

} from "@/util/i18n/document"

export default {

  components: { Nav, Footer },

  mounted() {

    this.$watch(

      "$i18n.locale",

      (newLocale, oldLocale) => {

        if (newLocale === oldLocale) {

          return

        }

        setDocumentLang(newLocale)

        setDocumentDirectionPerLocale(newLocale)

        setDocumentTitle(this.$t("app.title"))

      },

      { immediate: true }

    )

  }

}

</script>

<style>

/* ... */

</style>

The browser tab's title will now show our app's title translated in the currently active locale.

Async (Lazy) Loading of Translation Files

If our app's message files get past a certain size, and if we bundle them along with our main app bundle, as we're currently doing, we'll be making our app unnecessarily heavy for our users' first load. To counter this, we can use Webpack's code-splitting feature to lazy load translation files when they're needed. We'll need to update our i18n.js file, and the way we load message files.

import Vue from "vue"

import VueI18n from "vue-i18n"

import getBrowserLocale from "@/util/i18n/get-browser-locale"

import { supportedLocalesInclude } from "./util/i18n/supported-locales"

import {

  getChoiceIndex,

  setDefaultChoiceIndexGet

} from "./util/i18n/choice-index-for-plural"

import dateTimeFormats from "@/locales/date-time-formats"

import numberFormats from "@/locales/number-formats"

Vue.use(VueI18n)

function getStartingLocale() {

  const browserLocale = getBrowserLocale({ countryCodeOnly: true })

  if (supportedLocalesInclude(browserLocale)) {

    return browserLocale

  } else {

    return process.env.VUE_APP_I18N_LOCALE || "en"

  }

}

setDefaultChoiceIndexGet(VueI18n.prototype.getChoiceIndex)

VueI18n.prototype.getChoiceIndex = getChoiceIndex

const startingLocale = getStartingLocale()

const i18n = new VueI18n({

  locale: startingLocale,

  fallbackLocale: process.env.VUE_APP_I18N_FALLBACK_LOCALE || "en",

  messages: {},

  dateTimeFormats,

  numberFormats

})

const loadedLanguages = []

export function loadLocaleMessagesAsync(locale) {

  if (loadedLanguages.length > 0 && i18n.locale === locale) {

    return Promise.resolve(locale)

  }

  // If the language was already loaded

  if (loadedLanguages.includes(locale)) {

    i18n.locale = locale

    return Promise.resolve(locale)

  }

  // If the language hasn't been loaded yet

  return import(

    /* webpackChunkName: "locale-[request]" */ `@/locales/${locale}.json`

  ).then(messages => {

    i18n.setLocaleMessage(locale, messages.default)

    loadedLanguages.push(locale)

    i18n.locale = locale

    return Promise.resolve(locale)

  })

}

loadLocaleMessagesAsync(startingLocale)

export default i18n

First, we remove the loadLocaleMessages() function that Vue I18n installed for us; we won't need it since we're implementing a different way to load translation messages. We grab the starting locale as usual, but we keep it in a variable called startingLocale, since we'll need that a bit later. Also, instead of export defaulting the i18n instance as soon as we create it, we hold onto it in an i18n variable, which we'll need to access in our loading function.

To cache the message files we've already loaded, we maintain a loadedLanguages array. In our new function, loadLocaleMessagesAsync(), we first check if this array has any values. If it does, then we've loaded messages before, so we check if the requested locale is the same as the previous one. If that's true as well, we don't need to do anything, since presumably we've already loaded and cached the messages for the requested locale.

Otherwise, we check if the requested locale's messages have been loaded before. If they have, we simply set the requested locale as the active locale and we're done. If we haven't loaded messages for the requested locale, however, we need to do that. We perform a dynamic import for the locale message file with import().then(). This will cause Webpack to code-split our app bundle so that our locale message files are separated into individual files, and not included in the main app bundle.

The /* webpackChunkName: "locale-[request]" */ comment on the same line as the path to the message file instructs Webpack to give our message files special names when it code-splits them. The Arabic message file will be called locale-ar.json, for example, making it easier to track when the file is loaded. Before we export the i18n object, we call our new function loadLocaleMessagesAsync with the startingLocale to ensure that our app is initialized with a locale.

✋🏽 Heads up » By default, the Vue Webpack config will make Webpack prefetch all the async modules, including our locale message files. This kind of defeats the purpose of lazy loading, and we can turn it off by deleting the prefetch plugin using the vue.config.js file.

module.exports = {

  chainWebpack: config => {

    config.plugins.delete("prefetch")

  },

  pluginOptions: {

    i18n: {

      locale: "en",

      fallbackLocale: "en",

      localeDir: "locales",

      enableInSFC: false

    }

  }

}

Now let's update our App.vue component to handle the new, asynchronous loading of message files.

<template>

  <div id="app">

    <div v-if="isLoading">Loading...</div>

    <div v-else>

      <Nav v-on:localeChange="loadLocaleMessages" />

      <div class="container">

        <router-view />

      </div>

      <Footer />

    </div>

  </div>

</template>

<script>

import Nav from "@/components/Nav"

import Footer from "@/components/Footer"

import {

  setDocumentDirectionPerLocale,

  setDocumentTitle,

  setDocumentLang

} from "@/util/i18n/document"

import { loadLocaleMessagesAsync } from "@/i18n"

export default {

  data: () => ({

    isLoading: true

  }),

  mounted() {

    this.loadLocaleMessages(this.$i18n.locale)

  },

  methods: {

    loadLocaleMessages(locale) {

      this.isLoading = true

      loadLocaleMessagesAsync(locale).then(() => {

        setDocumentLang(locale)

        setDocumentDirectionPerLocale(locale)

        setDocumentTitle(this.$t("app.title"))

        this.isLoading = false

      })

    }

  },

  components: { Nav, Footer }

}

</script>

<style>

/* ... */

</style>

We need a loading state while a given message file is coming down the pipe. Otherwise, we'll attempt to display component UI before any messages are available. Notice that we're also subscribed to a localeChange event on the Nav component. We'll get to that in a minute. Suffice it to say for now that we'll simply be informed when a new locale is selected from the LocaleSwitcher via this event. When our App mounts, and again whenever the user selects a new locale, we call the new loadLocaleMessages() component method, which in turn calls the loadLocaleMessagesAsync() function in our i18n module, and sets the document attributes per the active locale once the message file has loaded.

<template>

  <div class="nav">

    <div class="nav__start">

      <!-- ... -->

    </div>

    <div class="nav__end">

      <p class="user-greeting">{{ $t("user_greeting", { name: "Adam" }) }}</p>

      <LocaleSwitcher v-on:change="$emit('localeChange', $event)" />

    </div>

  </div>

</template>

<script>

import LocaleSwitcher from "@/components/LocaleSwitcher"

export default {

  components: { LocaleSwitcher }

}

</script>

<style scoped>

/* ... */

</style>

Our Nav component passes through a localeChange event to its parent (our App in this case). To do so, it subscribes to a change event on the LocaleSwitcher component.

<template>

  <div class="locale-switcher">

    <select

      :value="$i18n.locale"

      @change.prevent="$emit('change', $event.target.value)"

    >

      <option :value="locale.code" v-for="locale in locales" :key="locale.code">

         {{locale.name}}

       </option>

    </select>

  </div>

</template>

<script>

import { getSupportedLocales } from "@/util/i18n/supported-locales"

import { loadLocaleMessagesAsync } from "@/i18n"

export default {

  data: () => ({ locales: getSupportedLocales() })

}

</script>

No longer setting i18n.locale directly, our LocaleSwitcher simply emits a change event whenever the user selects a new locale. And with that in place, our app is now code-split so that locale message files are lazy-loaded on request. On apps with larger message files and/or many locales, lazy loading should yield significant improvements to the initial app load, which is great for UX.

Localized Routes

In an SPA we may well want our routes to be localized. Instead of /foo, we would have /en/foo and /ar/foo. The locale code in the URI would determine the active locale of our app. This isn't too hard to do with Vue's first party, Vue Router, which we're already using in our app (albeit without localization).

import Vue from "vue"

import VueRouter from "vue-router"

import Home from "../views/Home.vue"

Vue.use(VueRouter)

const routes = [

  {

    path: "/",

    name: "home",

    component: Home

  },

  {

    path: "/about",

    name: "about",

    // route level code-splitting

    // this generates a separate chunk (about.[hash].js) for this route

    // which is lazy-loaded when the route is visited.

    component: () =>

      import(/* webpackChunkName: "about" */ "../views/About.vue")

  }

]

const router = new VueRouter({

  mode: "history",

  base: process.env.BASE_URL,

  routes

})

export default router

This is how the Vue CLI sets up our app when we select the router option during installation. We'll need to reorganize these routes to introduce a :locale route parameter.

import Vue from "vue"

import VueRouter from "vue-router"

import Home from "../views/Home.vue"

Vue.use(VueRouter)

const routes = [

  {

    path: "/",

    name: "home",

    component: Home

  },

  {

    path: "/about",

    name: "about",

    // route level code-splitting

    // this generates a separate chunk (about.[hash].js) for this route

    // which is lazy-loaded when the route is visited.

    component: () =>

      import(/* webpackChunkName: "about" */ "../views/About.vue")

  }

]

const router = new VueRouter({

  mode: "history",

  base: process.env.BASE_URL,

  routes

})

export default router

We've made our root / route redirect to /en or /ar, depending on locale resolution in our i18n module. All our app's routes are now nested under this latter, :/locale, route. This means that our routes are always localized, e.g. our about page route is either /en/about or /ar/about. We use the router.beforeEach() navigation guard to catch any route changes, and watch for the case when a route change means that we've changed locales. When this happens, we call our usual locale loading logic, starting with loadLocaleMessagesAsync(), which was previously in our App component. Notice that the top /:locale route is associated with a Root component. This is just a container with a <router-view> to render the component's children.

<template>

  <router-view />

</template>

Our locale switching effectively happens in the router.beforEach() navigation guard now, so we'll need to update our other modules to accommodate that. First, let's add a simple global event bus. This will allow any module in our app to listen for async locale message file loading events, one for begin loading and another for end loading, which we'll add in a moment.

import Vue from "vue"

const EventBus = new Vue()

export default EventBus

A separate Vue instance can serve as a perfect event bus. Now we can use this to emit our events in our i18n module.

import Vue from "vue"

import VueI18n from "vue-i18n"

import getBrowserLocale from "@/util/i18n/get-browser-locale"

import { supportedLocalesInclude } from "./util/i18n/supported-locales"

import {

  getChoiceIndex,

  setDefaultChoiceIndexGet

} from "./util/i18n/choice-index-for-plural"

import dateTimeFormats from "@/locales/date-time-formats"

import numberFormats from "@/locales/number-formats"

import EventBus from "@/EventBus"

Vue.use(VueI18n)

// ...

const loadedLanguages = []

export function loadLocaleMessagesAsync(locale) {

  EventBus.$emit("i18n-load-start")

  if (loadedLanguages.length > 0 && i18n.locale === locale) {

    EventBus.$emit("i18n-load-complete")

    return Promise.resolve(locale)

  }

  // If the language was already loaded

  if (loadedLanguages.includes(locale)) {

    i18n.locale = locale

    EventBus.$emit("i18n-load-complete")

    return Promise.resolve(locale)

  }

  // If the language hasn't been loaded yet

  return import(

    /* webpackChunkName: "locale-[request]" */ `@/locales/${locale}.json`

  ).then(messages => {

    i18n.setLocaleMessage(locale, messages.default)

    loadedLanguages.push(locale)

    i18n.locale = locale

    EventBus.$emit("i18n-load-complete")

    return Promise.resolve(locale)

  })

}

export default i18n

We simply $emit() an "i18n-load-start" event at the beginning of our loadLocaleMessagesAsync() function. Whenever we resolve our locale successfully, we emit an "i18n-load-complete" event. Now we have a global notification whenever we start and complete a locale message file load.

<template>

  <div id="app">

    <div v-if="isLoading">Loading...</div>

    <div v-else>

      <Nav />

      <div class="container">

        <router-view />

      </div>

      <Footer />

    </div>

  </div>

</template>

<script>

import Nav from "@/components/Nav"

import Footer from "@/components/Footer"

import EventBus from "@/EventBus"

export default {

  data: () => ({

    isLoading: true

  }),

  mounted() {

    EventBus.$on("i18n-load-start", () => (this.isLoading = true))

    EventBus.$on("i18n-load-complete", () => (this.isLoading = false))

  },

  components: { Nav, Footer }

}

</script>

<style>

/* ... */

</style>

We pull our EventBus into our App and use it to determine whether we should show our UI or our loading state. Our App component is no longer responsible for loading message files. That responsibility has moved over to our routing. All the App component is doing now making sure that the UI doesn't look broken while we're loading message files. To round out our localized routing logic, let's update our LocaleSwitcher to redirect to a localized route when the user changes her language.

<template>

  <div class="locale-switcher">

    <select :value="$i18n.locale" @change.prevent="changeLocale">

      <option :value="locale.code" v-for="locale in locales" :key="locale.code">

        {{locale.name}}

      </option>

    </select>

  </div>

</template>

<script>

import { getSupportedLocales } from "@/util/i18n/supported-locales"

export default {

  data: () => ({ locales: getSupportedLocales() }),

  methods: {

    changeLocale(e) {

      const locale = e.target.value

      this.$router.push(`/${locale}`)

    }

  }

}

</script>

We use the $router object—which Vue makes available to our components when we register the Vue Router plugin—to programmatically navigate to /ar if the user selects Arabic. That about does it. We now have localized routes in our Vue SPA.

Localized Router Links

Continuing our localized routing from the last section, let's take a look at the current state of our Nav component.

<template>

  <div class="nav">

    <div class="nav__start">

      <img alt="Vue logo" src="../assets/logo-circle-sm.png" />

      <router-link to="/">{{ $t("nav.home") }}</router-link>

      <router-link to="/about">{{ $t("nav.about") }}</router-link>

    </div>

    <div class="nav__end">

      <p class="user-greeting">{{ $t("user_greeting", { name: "Adam" }) }}</p>

      <LocaleSwitcher />

    </div>

  </div>

</template>

<script>

import LocaleSwitcher from "@/components/LocaleSwitcher"

export default {

  components: { LocaleSwitcher }

}

</script>

<style scoped>

/* ... */

</style>

Notice that our <router-link>s aren't really localized. The "/about" link will currently cause our app to error out since we only support "/en/about" and "/ar/about". Let's write a quick LocalizedLink component that we can use to solve this.

<template>

  <router-link :to="getTo()">

    <slot />

  </router-link>

</template>

<script>

export default {

  props: ["to"],

  methods: {

    getTo() {

      if (typeof this.to !== "string") {

        return this.to

      }

      const locale = this.$route.params.locale

      // we strip leading and trailing slashes and prefix

      // the current locale

      return `/${locale}/${this.to.replace(/^\/|\/$/g, "")}`

    }

  }

}

</script>

We wrap a <router-link> with our own custom logic, transforming any to string parameter we receive into one with a locale prefix. So if we get to="about", and the current locale is Arabic, we pass to="ar/about" to <router-link>. We can now use our LocalizedLink to fix the links in our Nav component.

<template>

  <div class="nav">

    <div class="nav__start">

      <img alt="Vue logo" src="../assets/logo-circle-sm.png" />

      <LocalizedLink to="/">{{ $t("nav.home") }}</LocalizedLink>

      <LocalizedLink to="/about">{{ $t("nav.about") }}</LocalizedLink>

    </div>

    <div class="nav__end">

      <p class="user-greeting">{{ $t("user_greeting", { name: "Adam" }) }}</p>

      <LocaleSwitcher />

    </div>

  </div>

</template>

<script>

import LocaleSwitcher from "@/components/LocaleSwitcher"

import LocalizedLink from "@/components/LocalizedLink"

export default {

  components: { LocaleSwitcher, LocalizedLink }

}

</script>

<style scoped>

/* ... */

</style>

Our links are working after this change. Now we can just pull in and use LocalizedLink without worrying about locales.

🔗 Resource » You can grab the code for the entire, completed app from GitHub.

Closing Up

That's it for this one, folks. We hope you enjoyed it, and that you learned a thing or two about Vue localization with the Vue I18n library. And if you want to give your team the ultimate translation experience, check out the Vue I18n Phrase in-context editor.

Installed with a few lines of code, the Phrase in-context editor will power up your Vue app with a powerful toolset, allowing your translators to work on app localization within the context of your app! This means no going back and forth between your app and the translation tooling: it’s all in one place. And don't worry, you can control whether translations happen in a sandbox or in the production version of your app.

The Vue I18n in-context editor comes with Phrase, a comprehensive localization solution with powerful features like a CLI, translation syncing, a beautiful web console for translators, and much more. Take a look at all Phrase features for developers, and see for yourself how it can make your life easier.