Creating a multilingual Single Page Application with Vue

Having a multilingual website provides a competitive advantage to businesses and organizations, while allowing them to expand their client base to international markets and potentially increase sales volumes.

This tutorial will show the basics on how to create a single page application with internationalization support, using Vue and the Vue-I18n internationalization plugin. We will also show you how you could optimize the structure of your HTML templates, based on the structure of your language files, and the use of "variables" inside your localized language strings.

file

Important: This tutorial assumes that you are comfortable with Vue, Node and NPM commands and syntax. It also assumes that you are familiar with concepts such as SPA, JSON, routing, internationalization and localization.

Disclosure

This tutorial summarizes many articles, Q&A from different forums, documentation of open source projects, and hours of "try and error" during the implementation.

Credit is granted to the authors of those projects, articles and documentation, and recognition to the knowledgeable people who give their time to answer questions in different support forums.

Additionally, we consider the resources used in this tutorial to be the best tool for the job in the context of our project requirements. We do not endorse nor are affiliated to any of the resources mentioned in here.

side notes

To make our tutorial closer to a "real" application (and far from a "Hello World" application), we are going to borrow some actual content from jerometranslations.com © web site.

WHAT ARE THE GOALS?

  • We want to have an internationalized application that supports different languages.
  • We want to be able to switch from one language to another within any page of our application.
  • We want to be able to add new supported languages with a minimum impact on our application code.
  • We want to allow translators and/or contributors to manage the language files apart from the application base code.

TLDR:

If you prefer, you can go directly to the GitHub repository.
You could also have a look at the demo application page that we will be creating
DEMO

table of contents

In this tutorial we will cover

  1. Installation and configuration
  2. Using the internationalization plugin
  3. Working with variables
  4. Adding new languages
  5. Switching between languages
  6. Managing our language files
  7. Additional resources

1. Installation and configuration

Creating the application

The following steps are optional. If you already have an application up and running, you may skip to step No. 3. You can see the repo for further reference.

1). Create a basic SPA (vue-multilingual in this tutorial) using Vue CLI 3 (v3.9.0 at the time of writing)


  >vue create vue-multilingual
  >cd vue-multilingual

 
2). Install vue-router to handle the application routing pages


  >npm install vue-router --save

 
3). To handle our localization files, we will be installing the Vue I18n internationalization plugin (v8.12.0 at the time of writing) from Kazuya Kawaguchi (kazupon)


  >npm install vue-i18n --save

 
4). Install other libraries as per your own requirements and needs, e.g. we are using Less CSS pre-processor, so


  >npm install -D less less-loader
Plugin configuration

Once our application is created, we first need to instantiate the i18n plugin. For this purpose, we will create a i18n.js file at the root level of the src folder. There, we will provide a default locale, and an object containing our supported languages.

In this example, we will be supporting English and French:


  // i18n.js
  import Vue from 'vue';
  import VueI18n from 'vue-i18n';

  Vue.use(VueI18n);

  const i18n = new VueI18n({
      locale: "en",
  });

  export const languages = {
      "en": "English",
      "fr": "Français"
  }

  export default {
      i18n
  }

 
Then we need to make our application aware of that instance in the main.js file.


  // main.js
  import Vue from 'vue'
  import App from './App.vue';
  . . .
  import i18n from "./i18n.js";

  Vue.config.productionTip = false;

  new Vue({
      . . .
      i18n,
      render: h => h(App),
  }).$mount('#app')

 
After the plugin has been instantiated, we can use it anywhere within our application by referencing it as this.$i18n.

2. Using the internationalization plugin

Our application is almost ready to consume the i18n internationalization plugin, but before that, we need to complete the three following steps:

a) Create our JSON language file(s): We need at least one containing the default language strings
b) Set the current language or locale
c) Set the source of the language's message, or language string(s)

a) JSON language files.
In our project, we will place the language JSON files in the following folder: src/assets/lang/

file

Our JSON language files will contain the language strings that will be rendered as the content of our application pages. For instance, the strings for a navigation menu will look like:


  en.json

  {
      "navigation": {
          "home": "Home",
          "about": "About",
          "services": "Services",
          "contact": "Contact"
      }
  }

 
For the points (b) and (c) above (locale and message), we will set them in our App.vue file, inside the beforeMount hook:


  // App.vue
  export default {
    name: 'app'
    data() {
        return {
            defaultLanguage: "en"
        }
    },
    beforeMount() {
      // get locale on page load
      let lang = !!localStorage.getItem("lang") ?
          localStorage.getItem("lang") :
          this.defaultLanguage;

      this.$i18n.locale = lang;
      this.$i18n.setLocaleMessage(lang, require(`./assets/lang/${lang}.json`));
    }
  }

 
Please note that we are trying to get the value of the variable lang from localStorage. If that variable doesn't exist or hasn't been set, we fallback to the declared default language.

IMPORTANT: In this tutorial, we are interacting directly with localStorage to persist the user's preferred language. In more complex applications, you may want to use a state management library like Vuex

b) Set the locale property of the i18n object from either the localStorage variable or the defaultLanguage


  this.$i18n.locale = lang;

 
c) Set the source language's file. We use setLocaleMessage() to load the corresponding JSON file.


  this.$i18n.setLocaleMessage(lang, require(`./assets/lang/${lang}.json`));
NOTE: More about the require() method later
template formatting

Inside out HTML template, we can now render our language strings with the following format $t("message")

From the navigation object menu in our JSON file example above, we could render the HTML like:


  <router-link to="/home">{{ $t("navigation.home") }}</router-link>
  <router-link to="/about">{{ $t("navigation.about") }}</router-link>
  <router-link to="/services">{{ $t("navigation.services") }}</router-link>
  <router-link to="/contact">{{ $t("navigation.contact") }}</router-link>
Optimizing the structure of our HTML templates

Now let's imagine that we have such navigation HTML code, and that we decide to add or remove some menu items. In that scenario, we wouldn't only need to update the language file but also the HTML code to adapt to the new layout. Every change in the layout will imply changing things in several places.

One of the advantages of dealing with objects, as in our JSON file, is that we can iterate over their properties to extract their values. Since we don't want to repeat ourselves, we can refactor the structure of our HTML template to respond dynamically to changes in the language JSON file.

So based on the following navigation object in the JSON file:


  en.json

  {
      "navigation": {
          "home": "Home",
          "about": "About",
          "services": "Services",
          "contact": "Contact"
      }
  }

 
We can refactor the structure of the HTML menu template like:


  <router-link v-for="(page, index) in $t('navigation')"
               v-bind:key="index"
               v-bind:to="index">{{ page }}</router-link>
NOTE: The v-for attribute allows us to pass the value, key pair (in that order) of the object of reference (used as (page, index) in our example above)
working with nested objects

We can apply the same methodology to optimize any of our HTML code, even with nested objects.

For instance, the services object from the JSON file, contains an object named sections, which also contains other nested objects (section1, section2, etc.) Each nested object may also contain other nested objects (e.g. paragraphs), and so on and so forth.

file

We can refactor the services HTML template to iterate over those nested objects like:


  <div class="services">
      <h2>{{  $t('services.heading2') }}</h2>
      <div class="section"
           v-for="(section, index) in $t('services.sections')"
           v-bind:key="index">
          <h3 class="section__heading">{{ section.heading3 }}</h3>
          <div class="section__content">
              <p v-for="(paragraph, index) in section.paragraphs"
                 v-bind:key="index"
                 class="section__paragraph">{{ paragraph }}</p>
          </div>
      </div>
  </div>

 
With that new HTML code, we don't have to worry of how many sections or paragraphs we have in each section, or if we want to add or remove some sections/paragraphs in the future.

IMPORTANT: It is recommended to use nested objects instead of arrays because some (third-party) translation tools (as we are intending to use) drop the content coming from arrays.

To maximize the benefits of this methodology, it's important to plan the structure of our application content ahead.

3. Working with variables

The i18n plugin allows to work with variables that may not be part of a translated string. As a typical example, let's explore the copyright statement in our JSON file:


  en.json

  {
      "companyName": "Jerome Translations",
      "footer": {
          "copyRight": "Copyright © 2012 - 2019 Jerome Translations. All rights reserved"
      }
  }

 
Which we can consume in our HTML template like


  <p>{{ $t("footer.copyRight") }}</p>

 
So far so good. However, that means that every year, we will have to edit all the language files to update the date of the current year; and this is not that good. This is where variables come in handy.

We can declare variables inside our strings between curly brackets like


  en.json

  {
      "companyName": "Jerome Translations",
      "footer": {
          "copyRight": "Copyright © 2012 - {dateYear} Jerome Translations. All rights reserved"
      }
  }

 
And set the corresponding value of the variable dateYear in our component like


  // Footer.vue
  data() {
      return {
          dateYear: new Date().getFullYear()
      }
  }

 
Then we can consume it as follow in our HTML template


  <p>{{ $t("footer.copyRight", {dateYear}) }}</p>

 
By doing this, we don't have to worry about editing any code in the years to come :)

IMPORTANT: Variable names must match in both our string and component. Variable names are CaSe sensitive

In our example above, the variable name dateYear in the string matches the variable name dateYear declared in the component. This may be a bit inconvenient since we may want to use other existing variables, instead of creating new matching ones.

For example, let's say that instead of the dateYear variable in our component, we already have another one named fullYear:


  // Footer.vue
  data() {
      return {
          fullYear: new Date().getFullYear()
      }
  }

 
We can assign the component's variable fullYear to the dateYear variable in the locale string, and use it in the HTML template with the following format:


  <p>{{ $t("footer.copyRight", {dateYear: fullYear}) }}</p>

 
If required, we could also assign hard-coded values to variables in the HTML template like


  <p>{{ $t("footer.copyRight", {dateYear: '2019'}) }}</p>
using more than one variable

It is also possible to pass more than one variable (comma separated) inside the curly brackets section like


  <p>{{ $t("contentSample", {var1, var2, var3, ...}) }}</p>

 
Or with programmatically (hard-coded) assigned values like


  <p>{{ $t("contentSample", {var1: 'value1', var2: 'value2', var3: 'value3', ...}) }}</p>
re-using locale properties

Additionally, we could re-use some of our existing strings in the JSON file.

For example, let's have a look at some properties


  en.json

  {
      "companyName": "Jerome Translations",
      "footer": {
          "copyRight": "Copyright © 2012 - {dateYear} Jerome Translations. All rights reserved"
      }
  }

 
The copyRight property contains the words Jerome Translations, which match the value of the companyName property above it. So instead of repeating those words, we could just re-use the companyName property inside the copyRight string with a "pointer" variable


  en.json

  {
      "companyName": "Jerome Translations",
      "footer": {
          "copyRight": "Copyright © 2012 - {dateYear} @:(companyName). All rights reserved"
      }
  }
IMPORTANT: Note the format of the pointer variable @:(companyName), which references an existing property in the same JSON file.

We could also have more than one pointer variable inside a locale string like:


  en.json

  {
      "foo": "Some text @:(bar), and @:(baz)",
  }
NOTE: Pointers to nested properties are also allowed, e.g. @:(foo.bar).

Please also note that we don't have to pass the pointer variable(s) in the HTML template; we only pass any other (reactive) variable(s) declared in our component, like dateYear in our previous example.
 

WARNING:  If we have nested objects that contain variables in our locale strings, and we are planning to optimize our HTML template as suggested in the section Optimizing the structure of our HTML templates, the value of the variables in the string won't be properly rendered inside the loop.  Basically, i18n variable rendering is not supported inside of v-for loops at this point.  In that specific case, you may need to implement your own work around. Please see issue #676 for "how-to".

4. Adding new languages

If we want to add another language to our application, we first need to create a JSON file containing the translated strings into the new target language (e.g. French.)

IMPORTANT: It's worth to mention that all JSON language files must have the same structure.

If our original default language (English) has the following navigation menu structure


  en.json

  {
      "navigation": {
          "home": "Home",
          "about": "About",
          "services": "Services",
          "contact": "Contact"
      }
  }

 
The French file must also have the same one


  fr.json

  {
      "navigation": {
          "home": "Accueil",
          "about": "À propos",
          "services": "Services",
          "contact": "Nous contacter"
      }
  }

 
Secondly, we have to edit the i18n.js file and declare the newly supported language(s) inside the languages object, e.g.


  // i18n.js 
  export const languages = {
      "en": "English",
      "fr": "Français",
      "es": "Español",
      "gr": "Deutsche",
      "ru": "Pусский"
  }

5. Switching between languages

We are going to create a "Language Selector" single file component, that we can include anywhere in our application. This component will have some HTML menu options to switch between the languages our application supports. It will also have a method to handle click events that will load the corresponding language as selected.

Example of our HTML language selector


  <a v-bind:class="'link' + [activeLanguage === 'en' ? ' active' : '']" @click="selectLanguage('en')">English</a>
  <a v-bind:class="'link' + [activeLanguage === 'fr' ? ' active' : '']" @click="selectLanguage('fr')">Français</a>
  <a v-bind:class="'link' + [activeLanguage === 'es' ? ' active' : '']" @click="selectLanguage('es')">Español</a>

 
The method selectLanguage will load the corresponding language file(s)


  LangSelector.vue

  export default {
      name: 'LangSelector',
      data() {
          return {
              activeLanguage: this.$i18n.locale
          }
      },
      methods: {
          selectLanguage: function (lang) {
              this.activeLanguage = lang; // update CSS class in selector
              this.$i18n.locale = lang;
              this.$i18n.setLocaleMessage(lang, require(`../../assets/lang/${lang}.json`));
              // persist selected language
              localStorage.setItem("lang", lang);
          }
      }
  }

 
Please note the selectLanguage method above is also updating the i18n.locale variable, as well as the lang variable in localStorage

NOTE: To dynamically load the corresponding language JSON file, we use require() as argument of setLocaleMessage()
optimizing the HTML template (again)

Since we already have a languages object in our i18n.js file, we can import it and iterate over it to optimize our HTML template


  LangSelector.vue

  import { languages } from "../../i18n.js";

  export default {
      name: 'LangSelector',
      data() {
          return {
              activeLanguage: this.$i18n.locale,
              supportedLanguages: languages
          }
      }
  }

 
After we imported the object and assigned it as the value of the supportedLanguages variable, we can refactor our HTML template to dynamically create the HTML language selector options like:


  <a v-for="(lang, index) in supportedLanguages"
     v-bind:class="'link' + [activeLanguage === index ? ' active' : '']"
     v-bind:key="index"
     @click="selectLanguage(index)">{{ lang }}</a>
Bulletproofing the load of language files

Imagine a scenario where a contributor translator informs the developer that they have completed the translation of a new target language (e.g. Spanish) and that the developer goes and edits the i18n.js file and adds the language to the languages object.

Even though the newly translated JSON file was completed ( es.json ), if the contributor forgot to actually commit the file to the repository, the functionality of our application will break while trying to load the es.json file.

NOTE: The require() method will try to load the file whether it exists or not, and will throw an error if the load fails. See setLocaleMessage() arguments for reference.

At this point, it would be a good idea to verify if the corresponding file exists before we try to load it, and fallback to the default language otherwise.

We will add a isLanguageAvailable() method to our component, which will receive the selected language as parameter, and return false if the file doesn't exist.


  LangSelector.vue

  isLanguageAvailable: function (lang) {
      let isAvailable = true;
      try {
          require.resolve(`../../assets/lang/${lang}.json`)
      } catch (error) {
          isAvailable = false;
      }
      return isAvailable;
  }
NOTE: The require.resolve() method will return the name of the file if it does exist without attempting to load it. It will throw an error that we may have to handle otherwise.

 
Now we can refactor our selectLanguage method to use isLanguageAvailable() and validate if the language file exists before we attempt to load it. We will fallback to the default language otherwise.


  LangSelector.vue

  selectLanguage: function (lang) {
      // if selected language doesn't exist, fallback to default
      let langSelected = this.isLanguageAvailable(lang) ?
          lang :
          this.defaultLanguage;

      this.activeLanguage = langSelected; // update CSS class in selector
      this.$i18n.locale = langSelected;
      this.$i18n.setLocaleMessage(langSelected, require(`../../assets/lang/${langSelected}.json`));
      // persist selected language
      localStorage.setItem("lang", langSelected);
  }

6. Managing our language (JSON) files

One of the goals of this tutorial was to allow translators and/or contributors to manage the language files apart from the application base code.

To achieve this, we will be using PO Editor third-party service. PO Editor is a "SaaS" application that we consider to be the best tool in the context of our project requirements.

file

Among the main reasons we chose this service are:

  • GitHub, Bitbucket, GitLab and VSTS (code hosting services) integration: Seamlessly adapts to existing repositories and CI/CD integration requirements.
  • Terms and translation strings can be imported directly from your repository files.
  • Supports a diversity of file formats: XML, JSON, CSV, PO, POT, etc.
  • Language files can be exported as commits to your repository, or downloaded locally.
    file
  • Provides an intuitive interface to translate "locale" strings into a new target language against the default language.
    file
  • Starts with a "free" version, which supports up to 1,000 translated strings. Paid options available to match your volume requirements.
  • Offers "unlimited" contributors, projects and supported languages.
  • Contributors can be added as per language basis, and they may or may not have access to other languages
    file
  • Allows settings for open source and private/public projects
DISCLAIMER: We do not endorse the use of this service. You may use any other tool according to your preference and needs, or not to use a third-party service at all.

7. Additional resources

The subject of internationalization is very wide and this tutorial doesn't cover topics like pluralization, date, time and number localization.

However, we hope it will give you the foundations from where you can easily implement those topics in your project. We also hope this tutorial will help you in structuring the contents of your Vue website, regardless if you need or not a multilingual website today.

Related documentation and articles:

Vue Configuration
Vuex: State Management library
Vue-i18n plugin documentation
Internationalization reference
PO Editor
Source content for this project (c)