Vue 3 i18n: Building a multi-language app with locale switcher

Internationalization is an important yet often overlooked step in software development. In fact, software localization would not be possible without internationalization (i18n). Setting up a Vue 3 website with i18n support may sound daunting at first, but it’s actually easier than you might think. For this tutorial, we will be using Vue I18n, a great package from the core Vue devs, to show you how to build a multi-language app with a locale switcher. We’ll also discuss some Vue I18n best practices that will come in really handy.

In this article, we are going to cover the following topics:

  • Vue I18n setup.
  • Adding support for multiple languages.
  • Storing and using translations.
  • Implementing pluralization.
  • Datetime and number localization.
  • Switching locale.
  • Integrating Vue Router and making it work with multiple locales.
  • Lazy loading of translation files based on the chosen locale.
  • Reading the user’s preferred locale and adjusting the default language based on it.

The source code demonstrating the Vue localization example is available on GitHub. The working demo can be found at lokalise-vue3-i18n.web.app.

Please note that this article covers Vue 3 only. If you are looking for a Vue 2 tutorial, please check this article instead.

    Prerequisites

    To follow along, you’ll need to install the following:

    • Node 16+
    • Your favorite code editor and a command-line interface

    I will also assume that you are familiar with the Vue framework.

    Creating the Vue app

    Let’s start with creating the Vue 3 app by running the following command:

    npm init vue@latest

    You might be asked to install some additional tools, so choose “yes” to continue.

    Next, give your app a name — I’m calling mine lokalise-vue.

    Here are other config options I’ve chosen:

    • Add TypeScript — no
    • Add JSX support — no
    • Add Vue Router — yes
    • Add Pinia — no
    • Add Vitest — no
    • Add an E2E testing solution — no
    • Add ESLint — no

    Of course, your setup might be different, but please note that we’ll require Vue Router later in this article. All other components are not mandatory.

    Finally, go to the project directory, install the necessary dependencies, and mare sure your app is booting correctly:

    cd lokalise-vue
    npm install
    npm run dev

    You can visit the localhost:5173 URL in your browser to ensure everything is working well.

    Adjusting the views

    Before proceeding, let’s prepare some views for demonstration purposes. Upon creation, your app should contain two views: Home and About. Let’s tweak the src/views/HomeView.vue first:

    <template>
      <main>
        <h1>Welcome to the Vue 3 I18n tutorial!</h1>
        <p>This tutorial was brought to you by Lokalise.</p>
        <p>This page has been visited 30 times.</p>
      </main>
    </template>
    

    Open the src/views/AboutView.vue file and provide sample data inside it:

    <template>
      <div>
        <h1>About us</h1>
    
        <p>Donations raised: $1456.00</p>
      </div>
    </template>
    

    Also tweak the src/App.vue file as follows:

    <template>
      <RouterView />
    </template>

    Additionally, you can remove all files from the src/components directory as we are going to create our own components later.

    Adding navigation

    Before moving on to the next section, let’s add some basic navigational components. Create a new src/components/Nav.vue:

    <template>
      <nav>
        <ul>
          <li>
            <RouterLink to="/">Home</RouterLink>
          </li>
          
          <li>
            <RouterLink to="/about">About us</RouterLink>
          </li>
        </ul>
      </nav>
    </template>

    Use this newly created component in the src/App.vue file:

    <script setup>
    import Nav from "@/components/Nav.vue";
    </script>
    
    <template>
      <Nav></Nav>
      <RouterView />
    </template>

    For the sake of completeness, here’s the content of the src/router/index.js file:

    import { createRouter, createWebHistory } from 'vue-router'
    import HomeView from '../views/HomeView.vue'
    
    const router = createRouter({
      history: createWebHistory(import.meta.env.VITE_BASE_URL),
      routes: [
        {
          path: '/',
          name: 'home',
          component: HomeView
        },
        {
          path: '/about',
          name: 'about',
          component: () => import('../views/AboutView.vue')
        }
      ]
    })
    
    export default router
    

    The VITE_BASE_URL is an environment variable that should be created in the .env file located in the project root (create this file if it doesn’t exist):

    VITE_BASE_URL=/

    That’s it!

    Installing and configuring the Vue I18n plugin

    Next, let’s install the core Vue I18n library and the unplugin-vue-i18n:

    npm install vue-i18n@9 @intlify/unplugin-vue-i18n

    You can also add those directly to the package.json (dependencies section):

    "dependencies": {
      "@intlify/unplugin-vue-i18n": "^1.4.0",
      "vue": "^3.2.45",
      "vue-i18n": "^9.2.2",
      "vue-router": "^4.1.6"
    },

    Don’t forget to run npm install.

    Next, we need to configure the plugin within the vite.config.js file, so change it as follows:

    import { fileURLToPath, URL } from 'node:url'
    import { resolve, dirname } from 'node:path'
    import { defineConfig } from 'vite'
    import vue from '@vitejs/plugin-vue'
    import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'
    
    export default defineConfig({
      plugins: [
        vue(),
        VueI18nPlugin({
          include: resolve(dirname(fileURLToPath(import.meta.url)), './src/i18n/locales/**'), // provide a path to the folder where you'll store translation data (see below)
        })
      ],
      resolve: {
        alias: {
          '@': fileURLToPath(new URL('./src', import.meta.url))
        }
      }
    })
    

    Please note that if the interpolation or pluralization is not working in production, you can also try setting the runtimeOnly option to false in the config. For example:

    export default defineConfig({
      plugins: [
        vue(),
        VueI18nPlugin({
          runtimeOnly: false, // <--- add this
          include: resolve(dirname(fileURLToPath(import.meta.url)), './src/i18n/locales/**'),
        })
      ],
      // ...
    })
    

    That should do the trick, but there’s some more work awaiting us.

    Let’s create a new file, src/i18n/index.js, to host our internationalization-related settings:

    import { createI18n } from "vue-i18n";
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE, // <--- 1
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE, // <--- 2
      legacy: false, // <--- 3
    })

    Here’s a more in-depth discussion of the code:

    1. We are setting the default locale (language) for our application. Its value will be fetched from the environment variable that we’ll add in a moment. Please note that all environment variables must start with the VITE_ prefix.
    2. We are adding a fallback locale. If translations for the currently set locale are unavailable, Vue will try to read values from the fallback locale instead.
    3. Setting the legacy parameter is very important to ensuring that the i18n plugin properly works with Vue 3.

    Okay, great!

    Now let’s create an .env file in the project root and add two environment variables inside it:

    VITE_DEFAULT_LOCALE=en
    VITE_FALLBACK_LOCALE=ru

    I’ll use English as a base language and Russian as a fallback, but please feel free to choose any other locales.

    At this point, we can use the plugin inside the src/main.js file, so let’s modify it in the following way:

    import { createApp } from 'vue/dist/vue.esm-bundler' // <--- 1
    import App from './App.vue'
    import router from './router'
    import i18n from "./i18n" // <--- 2
    
    createApp(App).
      use(router).
      use(i18n). // <--- 3
      mount('#app')

    Main things to note here:

    1. I’m using an ESM bundler to get rid of some annoying warnings in the console and for general optimization.
    2. Import the I18n object.
    3. Use the I18n plugin when mounting the app.

    Of course, you can import some CSS here but I won’t do this for the sake of simplicity.

    Performing basic translations with the Vue I18n plugin

    Adding translation messages

    So, our I18n plugin is now configured but it’s not doing anything as we haven’t provided any translations yet! Let’s start with the simplest case and translate plain text within our views. Basically, we should extract the hard-coded strings into a separate file and replace these strings with keys that will act as placeholders. Once the app is served to the end user, these keys will be replaced with the actual texts based on the currently set locale. This process is known as an internationalization and it’s often confused with localization which is a different thing. Localization means adapting dates, times, currencies, images, and other assets to suit a specific country or a region. You can read more about these differences in another of my articles.

    Therefore, let’s open the src/i18n/index.js file and add English translations in the key-value format:

    import { createI18n } from "vue-i18n";
    
    const messages = {
      en: {
        nav: {
          home: "Home",
          about: "About"
        },
        home: {
          header: "Welcome to the Vue 3 I18n tutorial!",
          created_by: "This tutorial was brought to you by Lokalise."
        },
        about: {
          header: "About us"
        }
      }
    }
    
    // ...

    So, the header and created_by are the translation keys that we will use in our source code. Please note that I’m nesting these keys into “namespaces” for better manageability. You can learn more about some best practices for key management and organization in our blog.

    Also note that the root key (en) must be named after the locale that you are planning to support.

    Let’s also provide Russian translations:

    import { createI18n } from "vue-i18n";
    
    const messages = {
      en: {
        // ... english translations here ...
      },
      ru: {
        nav: {
          home: "Главная",
          about: "О нас"
        },
        home: {
          header: "Добро пожаловать в руководство Vue 3 I18n!",
          created_by: "Это руководство создано для вас компанией Lokalise."
        },
        about: {
          header: "О нас"
        }
      }
    }
    
    // ...

    As you can see, the keys have the same names but their values are different — that’s the core idea of the internationalization process.

    Now we can pass these messages to the createI18n() function inside the same file:

    // ...
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE,
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE,
      legacy: false,
      messages
    })

    Using translation keys

    The translations are now ready to be used. Next, we have to replace the hard-coded texts with the keys and ask the I18n plugin to properly handle those keys. By default, the Vue I18n plugin is not globally injected into the app, so you’ll have to include it manually in every view. Here’s an example for the HomeView.vue:

    <template>
      <main>
        <h1>Welcome to the Vue 3 I18n tutorial!</h1>
        <p>This tutorial was brought to you by Lokalise.</p>
        <p>This page has been visited 30 times.</p>
      </main>
    </template>
    
    <script>
      // Add this script:
      import { useI18n } from 'vue-i18n'
    
      export default {
        setup() {
          const { t } = useI18n()
    
          return { t }
        }
      }
    </script>

    Here we are importing the Vue I18n plugin, extracting the t function from it (it simply means “translate”), and exposing this function to the view.

    The t function can now be used to handle our translation keys:

    <template>
      <main>
        <h1>{{ t("home.header") }}</h1>
        <p>{{ t("home.created_by") }}</p>
        <p>This page has been visited 30 times.</p>
      </main>
    </template>
    
    <script>
      // ...
    </script>

    We are simply passing the key names to this function so that the keys are properly replaced with the corresponding messages once the app is served to the user. As long as our keys are nested, we have to use the “dot notation”. For example, the header key is nested under the home “namespace”, therefore we’re saying home.header.

    Globally injecting the translate function

    Exposing the t function manually is fine, but you might find it overly tedious as you’ll have to do this in every view and component. Instead, it’s possible to globally inject all i18n-related goodies into your app. To achieve this, add a new option to the src/i18n/index.js file:

    // ... your translations ...
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE,
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE,
      legacy: false,
      globalInjection: true, // <--- add this
      messages
    })

    Once you’ve set the globalInjection to true, all your views and components should have access to the $t function and it will no longer be necessary to import manually. Please note that starting from vue-i18n v9.2-beta.34 this option is set to true by default, so by the time you’re reading this tutorial chances are there’s no need to provide this option manually anymore.

    Let’s adjust the HomeView.vue:

    <template>
      <main>
        <h1>{{ $t("home.header") }}</h1>
        <p>{{ $t("home.created_by") }}</p>
        <p>This page has been visited 30 times.</p>
      </main>
    </template>

    Now we’ll do something similar in the src/views/AboutView.vue file:

    <template>
      <div>
        <h1>{{ $t("about.header") }}</h1>
    
        <p>Donations raised: $1456.00</p>
      </div>
    </template>
    

    Finally, let’s use translation keys in the src/components/Nav.vue file:

    <template>
      <nav>
        <ul>
          <li>
            <RouterLink to="/">{{ $t("nav.home") }}</RouterLink>
          </li>
          
          <li>
            <RouterLink to="/about">{{ $t("nav.about") }}</RouterLink>
          </li>
        </ul>
      </nav>
    </template>

    At this point, you can check that everything is working properly by running npm run dev and proceeding to localhost:5173. As long as our default locale was set to English, you should see English messages. We don’t have any controls to switch between the supported languages yet, so you can open the .env file and set the VITE_DEFAULT_LOCALE to ru to make sure the Russian translations are displaying properly. Don’t forget to set the default locale to en once you are done testing.

    Vue I18n: Check if a translation exists

    One question you might ask is: How do I check if a translation exists at all? That’s a good question! First of all, it’s important to understand how exactly the translation is sought:

    1. First, the vue-i18n plugin will search for a requested key in the current locale. If the corresponding translation is found, it’s returned right away. If not, proceed to step 2.
    2. If the translation cannot be found in the current locale, vue-i18n will check if there are any fallback rules defined. If a fallback locale is available and the key can be found for that locale, the corresponding translation is returned. In development mode. you’ll also see a console warning message saying that the key can’t be found in the current locale and that the fallback locale will now be searched.
    3. If the fallback locale is not set or the translation cannot be found there either, the key name itself will be returned as a translation.

    This means that you can create a simple condition and make sure the fetched translation does not equal the key name:

    if(t("missing.key") === "missing.key") {
      console.log("Can't find this translation!")
    } else {
      console.log("Here's your translation", t("missing.key"))
    }

    However, there’s even a simpler method: you can use the $te function, which is also globally injected if the globalInjection was set to true. This function accepts two arguments: the key name and the locale to search (which is optional). This function returns true if the key has been found, and false otherwise. Therefore, you can say something like this:

    if($te("missing.key")) {
      console.log("Here's your translation", t("missing.key"))
    } else {
      console.log("Can't find this translation!")
    }

    Advanced translation features with Vue I18n

    Now we know how to translate simple texts, but in some cases this might not be enough. In this section, we’ll see how to perform interpolation and pluralization, and localization of numbers, dates, and times in Vue 3.

    Interpolation

    Interpolation enables you to add dynamic values within static translation messages. For example, suppose you want to greet a user by their name. But the problem is, there are so many names in world and therefore we simply cannot hard-code it right into the translation! To overcome this problem, we can use interpolation and dynamically inject the current user’s name into the text when the app is served. In other words, your translation turns into something like “Hello, USERNAME_HERE”.

    So, let’s see how to interpolate custom values into translations. To achieve this, we should introduce special placeholders written in the following format: {placeholder_title}. These placeholders should be added directly into the translation texts, while their values will be provided when calling the $t function.

    Open the src/i18n/index.js file and make the following changes:

    const messages = {
      en: {
        home: {
          header: "Welcome to the Vue 3 I18n tutorial!",
          created_by: "This tutorial was brought to you by {company}." // <---
        },
        // other translations omitted ...
      },
      ru: {
        home: {
          header: "Добро пожаловать в руководство Vue 3 I18n!",
          created_by: "Это руководство создано для вас компанией {company}." // <---
        },
        // other translations omitted ...
      }
    }
    
    // ... other code ...

    Our placeholder is called company — please note that it must have the same name for all languages. If the placeholder value is not provided, it will be empty by default. Let’s now provide the value inside the src/views/HomeView.vue:

    <template>
      <main>
        <h1>{{ $t("home.header") }}</h1>
        <p>{{ $t("home.created_by", {company: "Lokalise"}) }}</p>
        <p>This page has been visited 30 times.</p>
      </main>
    </template>

    The $t function accepts the second optional argument containing placeholder names and values that should be interpolated into the translation. Make sure to pass the same placeholder as you provided in the translation. If needed, a single translation can contain multiple placeholders.

    Pluralization

    Pluralization is another important feature that developers commonly utilize. It allows you to display different messages based on a given number. For instance, in English you would say “0 apples” and “5 apples”, but “1 apple”. English pluralization rules are quite simple, but other languages (for example, Slavic ones) are more complex. For instance, in Russian you’d say “0 яблок”, “5 яблок”, “1 яблоко”, and “3 яблока”. Crazy, right? The Vue I18n plugin has pluralization support out of the box, but unfortunately it only “knows” about the English language — all other rules have to be provided separately.

    To provide Russian pluralization rules, we’ll create a new file – src/i18n/rules/pluralization.js:

    function ruPluralizationRules(
      choice,
      choicesLength
    ) {
      if (choice === 0) {
        return 0
      }
    
      const teen = choice > 10 && choice < 20
      const endsWithOne = choice % 10 === 1
      if (!teen && endsWithOne) {
        return 1
      }
      if (!teen && choice % 10 >= 2 && choice % 10 <= 4) {
        return 2
      }
    
      return choicesLength < 4 ? 2 : 3
    }
    
    export default {
      ru: ruPluralizationRules
    };

    To create rules for other languages, you can use this table which has pluralization information for all languages.

    Now we’ll use our rules in the src/i18n/index.js file:

    import { createI18n } from "vue-i18n"
    import pluralRules from "./rules/pluralization" // <--- add this 
    
    // ... translations ...
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE,
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE,
      legacy: false,
      globalInjection: true,
      messages,
      pluralRules // <--- add this 
    })

    With these rules in place, let’s add new translations into the same file:

    const messages = {
      en: {
        home: {
          num_visits: "This page hasn't been visited 🙁 | This page has been visited {n} time | This page has been visited {n} times"
        }
        // ... other translations omitted ...
      },
      ru: {
        home: {
          num_visits: "Страницу не посещали 🙁 | Страницу посетили {n} раз | Страницу посетили {n} раза | Страницу посетили {n} раз"
        }
        // ... other translations omitted ...
      }
    }

    For English, we are providing three different translations separated with the “pipe” (|) character: when there are no visits, when there is one visit, and when there are many visits. For Russian, we have to provide four different cases. Please note that in this case, the placeholder must be named either n or count so that it’s properly handled by the I18n plugin.

    Now we can use the $t function within the src/views/HomeView.vue file and provide any number as the second argument:

    <template>
      <main>
        <h1>{{ $t("home.header") }}</h1>
        <p>{{ $t("home.created_by", {company: "Lokalise"}) }}</p>
        <p>{{ $t("home.num_visits", 30) }}</p>
      </main>
    </template>

    Please note that it’s not necessary to give your placeholder a name in this case. You can try changing this number to make sure that the displayed message changes accordingly.

    Also note that previously you had to use Vue I18n tc function, but now it’s considered obsolete and works only in legacy mode.

    Great job!

    Numbers and currencies

    It’s no secret that there are numerous currencies in the world: US dollars, euros, Russian roubles, Japanese yen, and so on. The Vue I18n plugin enables you to format numbers using different currency formats based on the currently chosen locale. We have to provide these formatting rules manually, but the plugin does the heavy lifting for us.

    Create a new src/i18n/rules/numbers.js file:

    export default {
      en: {
        currencyFormat: {
          style: "currency",
          currency: "USD"
        }
      },
      ru: {
        currencyFormat: {
          style: "currency",
          currency: "RUB"
        }
      }
    }
    

    You can find additional formatting options in this document.

    Now add these rules to the src/i18n/index.js file:

    import { createI18n } from "vue-i18n"
    import pluralRules from "./rules/pluralization"
    import numberFormats from "./rules/numbers.js" // <--- add this
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE,
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE,
      legacy: false,
      globalInjection: true,
      messages,
      pluralRules,
      numberFormats  // <--- add this
    })

    Add a new translation:

    const messages = {
      en: {
        about: {
          donations: "Donations raised: {donations}"
        }
        // ... other translations omitted ...
      },
      ru: {
        about: {
          donations: "Пожертвований собрано: {donations}"
        }
       // ... other translations omitted ...
      }
    }
    
    // ... other code omitted ...

    Now let’s use this translation inside the src/views/AboutView.vue file and perform number formatting:

    <template>
      <div>
        <h1>{{ $t("about.header") }}</h1>
    
        <p>{{ $t("about.donations", { donations: $n(1456, "currencyFormat") }) }}</p>
      </div>
    </template>
    

    To format numbers, we use the $n function, which is also globally injected. Now for the English locale, you’ll see “$1,456.00” whereas for Russian this sum will change to “1 456,00 ₽”.

    Date and time

    The final topic that we’ll cover in this section is related to datetime localization. Thing is, different countries use different date and time formatting and we need to adapt the displayed information based on the currently set locale. Let’s create yet another file called src/i18n/rules/datetime.js:

    export default {
      en: {
        shortFormat: {
          dateStyle: "short"
        },
        longFormat: {
          year: 'numeric', month: 'short', day: 'numeric',
          weekday: 'short', hour: 'numeric', minute: 'numeric'
        }
      },
      ru: {
        shortFormat: {
          dateStyle: "short"
        },
        longFormat: {
          year: 'numeric', month: 'short', day: 'numeric',
          weekday: 'short', hour: 'numeric', minute: 'numeric'
        }
      }
    }

    Here we are providing two formats: short and long. Of course, you can add as many formats as needed. Please refer to this document to learn about other formatting options.

    Now use these rules in the src/i18n/index.js file:

    import { createI18n } from "vue-i18n"
    import pluralRules from "./rules/pluralization"
    import numberFormats from "./rules/numbers.js"
    import datetimeFormats from "./rules/datetime.js" // <--- add this
    
    // ...
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE,
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE,
      legacy: false,
      globalInjection: true,
      messages,
      pluralRules,
      numberFormats,
      datetimeFormats // <--- add this
    })

    Open the src/views/HomeView.vue app to display the current date and time using the “long” format:

    <template>
      <main>
        <h1>{{ $t("home.header") }}</h1>
        <p>{{ $t("home.created_by", {company: "Lokalise"}) }}</p>
        <p>{{ $t("home.num_visits", 1) }}</p>
        <p>{{ $d(new Date(), "longFormat") }}</p>
      </main>
    </template>

    To format datetime, we are using the $d function. Try changing the longFormat to shortFormat to observe the differences.

    Storing Vue 3 translations in JSON files

    Please find more information on managing and translating JSON files in our tutorial.

    Multiple Vue I18n translation files

    We can keep all our translations in the i18n/index.js file, but at some point this becomes very inconvenient. Why?

    • If you need to support many different languages, it becomes increasingly complex to navigate your translations.
    • Moreover, if your app is large, you might have hundreds or even thousands of translation keys, which will result in a huge JS file.
    • It will be impossible to introduce translation lazy loading. Thing is, your users probably won’t view your website in all the available locales; therefore, the translations should be loaded on demand to optimize performance. We’ll see how to implement lazy loading later in this article.
    • Finally, you might need to hire professional translators to add and/or review your texts. However, translators are usually less technically savvy than software developers, and they might have problems modifying JS files (they might even unintentionally break something).

    So, to overcome all these problems I recommend storing all your translations in separate JSON files. For now we’re going to be using a “one locale, one file” approach, but as your app grows you can separate translations into smaller files.

    First, create the src/i18n/locales/en.json file and place all English translations inside it:

    {
      "nav": {
        "home": "Home",
        "about": "About"
      },
      "home": {
        "header": "Welcome to the Vue 3 I18n tutorial!",
        "created_by": "This tutorial was brought to you by {company}.",
        "num_visits": "This page hasn't been visited 🙁 | This page has been visited {n} time | This page has been visited {n} times"
      },
      "about": {
        "header": "About us",
        "donations": "Donations raised: {donations}"
      }
    }

    Note that we don’t need to provide the top-level en key anymore.

    And here are the contents of the src/i18n/locales/ru.json file:

    {
      "nav": {
        "home": "Главная",
        "about": "О нас"
      },
      "home": {
        "header": "Добро пожаловать в руководство Vue 3 I18n!",
        "created_by": "Это руководство создано для вас компанией {company}.",
        "num_visits": "Страницу не посещали 🙁 | Страницу посетили {n} раз | Страницу посетили {n} раза | Страницу посетили {n} раз"
      },
      "about": {
        "header": "О нас",
        "donations": "Пожертвований собрано: {donations}"
      }
    }

    These files won’t be loaded automatically, so let’s import them in the src/i18n/index.js file and use their translations:

    import { createI18n } from "vue-i18n"
    import pluralRules from "./rules/pluralization"
    import numberFormats from "./rules/numbers.js"
    import datetimeFormats from "./rules/datetime.js"
    import en from "./locales/en.json" // <--- add this
    import ru from "./locales/ru.json" // <--- add this
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE,
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE,
      legacy: false,
      globalInjection: true,
      messages: {
        en, // <--- add this
        ru // <--- add this
      },
      pluralRules,
      numberFormats,
      datetimeFormats
    })

    From the user’s perspective, nothing has changed. However, we’ve neatly organized our translations and made an important step toward lazy loading. Nice!

    Uploading translation files to Lokalise

    Managing translations for multiple languages is a pretty complex task, especially for larger sites. Things become even more complex if you have limited proficiency in the supported languages. What should you do in such cases? Use a proper translation management system!

    Meet Lokalise, the leading translation management service which allows you to easily manage translation files, collaborate with translators, order professional translations, enable integrations with 50+ tools, and much more. Throughout this section, I will briefly explain how to get started with Lokalise. For now, you will need to perform the following steps:

    • Grab your free trial (no credit card is required).
    • Proceed to Personal profile > API tokens.
    • Generate a new read-write token (make sure to keep it safe).
    • Download CLIv2 and unpack it somewhere on your PC.
    • cd into the created directory
    • Create a new Lokalise project by running lokalise2
      project create --name Vue3 --token YOUR_TOKEN_HERE --languages
      "[{\"lang_iso\":\"ru\"},{\"lang_iso\":\"en\"}]" --base-lang-iso "en"
      . Adjust the supported languages as necessary.
    • The above command is going to return an object with the project details. Copy the project_id as we will need it later.

    Now we’ll upload our translation files to Lokalise by running the following commands:

    lokalise2 file upload --lang-iso en --file "PATH_TO_PROJECT\src\i18n\locales\en.json" --project-id PROJECT_ID --token YOUR_TOKEN
    lokalise2 file upload --lang-iso ru --file "PATH_TO_PROJECT\src\i18n\locales\ru.json" --project-id PROJECT_ID --token YOUR_TOKEN

    The PROJECT_ID is the identifier that you received in the previous step when creating a new project.

    Switching locales in a Vue 3 app

    The next big feature that we’re going to implement is the ability to switch the currently set locale and persist it inside the page URL. We’ll also see how to change the language dynamically.

    Introducing the language switcher

    First things first: we should create a new component that allows choosing of the app language. This component will live inside the src/components/LanguageSwitcher.vue file. Let’s perform some initial setup as follows:

    <template>
    
    </template>
    
    <script>
      import { useI18n } from 'vue-i18n'
    
      export default {
        setup() {
          const { t, locale } = useI18n()
        }
      }
    </script>

    You’ve already seen the t function, but the locale object is new. Basically, it represents the currently set locale and we can use it to switch language in the following way: locale.value = newValue.

    Now we should discuss two important matters: how to display the list of all the supported locales and how to set a new locale properly.

    Listing the available locales

    Speaking of the first question, there are at least two answers. The simplest thing we could do is extract all the available locales directly from the i18n object:

    const { t, locale, availableLocales } = i18n

    Then, the availableLocales can be used in the component to properly build a collection of options for the select tag. This will work in our current setup because we load all the translations when creating an I18n instance. However, it will not work when we introduce lazy loading because, in this case, only one locale will be initially loaded. Therefore, instead of relying on the availableLocales, let’s add a new environment variable to the .env file:

    VITE_SUPPORTED_LOCALES=en,ru

    Next, I’d like to create a special “helper” plugin to take care of working with our locales. Add a new src/i18n/translation.js file with the following content:

    const Trans = {
      get supportedLocales() {
        return import.meta.env.VITE_SUPPORTED_LOCALES.split(",")
      }
    }
    
    export default Trans

    We turn the list of supported locales into an array so that it can be iterated over it inside the component.

    Now use this method within the LanguageSwitcher.vue component:

    <template>
    
    </template>
    
    <script>
      import { useI18n } from 'vue-i18n'
      import Tr from "@/i18n/translation"
    
      export default {
        setup() {
          const { t, locale } = useI18n()
    
          const supportedLocales = Tr.supportedLocales
    
          return { supportedLocales }
        }
      }
    </script>

    So far, so good.

    Performing locale switching

    And what about locale switching? Actually, the locale object can be used as a model so theoretically you could create a dropdown with all the supported locales and pass the locale as a v-model:

    <select v-model="locale">
      <option
        v-for="sLocale in supportedLocales"
        :key="`locale-${aLocale}`"
        :value="aLocale"
      >{{ sLocale }}</option>
    </select>

    Next, you can observe changes to the locale and switch the currently set language:

    watch(locale, (newLocale) => {
      // set the new locale
    })

    This approach will work for a simpler setup; however, there’s a problem: later we want to introduce lazy loading for translation files and update the page URL to display the new locale code. Therefore, simply watching the locale changes won’t work. Instead, we are going to use a special change action, bind it to the select tag, and perform all the necessary steps once a new item has been selected.

    So, let’s add a new method to the setup() and expose all the necessary objects:

    <template>
    
    </template>
    
    <script>
      import { useI18n } from 'vue-i18n'
      import Tr from "@/i18n/translation"
    
      export default {
        setup() {
          const { t, locale } = useI18n()
    
          const supportedLocales = Tr.supportedLocales
    
          const switchLanguage = async (event) => { // <--- 1
            const newLocale = event.target.value // <--- 2
    
            await Tr.switchLanguage(newLocale) // <--- 3
          }
    
          return { t, locale, supportedLocales, switchLanguage } // <--- 4
        }
      }
    </script>

    Here are the important parts:

    1. switchLanguage should be an async function because later we’re going to load translation files inside it.
    2. The newly chosen language will be stored inside the newLocale constant.
    3. The actual language switching will be handled by our Translation helper.
    4. Finally, we expose all the necessary objects.

    Now let’s add a select tag:

    <template>
      <select @change="switchLanguage">
        <option
          v-for="sLocale in supportedLocales"
          :key="`locale-${sLocale}`"
          :value="sLocale"
          :selected="locale === sLocale"
        >
          {{ t(`locale.${sLocale}`) }}
        </option>
      </select>
    </template>

    Here, we’re iterating over all the available locales and displaying their names. I’d like to translate the language names, so let’s add new translations to the src/i18n/locales/en.json file:

    {
      "locale": {
        "en": "English",
        "ru": "Russian"
      },
      // ...
    }

    src/i18n/locales/ru.json:

    {
      "locale": {
        "en": "Английский",
        "ru": "Русский"
      },
      // ...
    }

    Great!

    Using the switcher component

    Let’s also display this new component inside the src/components/Nav.vue:

    <template>
      <nav>
        <ul>
          <li>
            <RouterLink to="/">{{ $t("nav.home") }}</RouterLink>
          </li>
          
          <li>
            <RouterLink to="/about">{{ $t("nav.about") }}</RouterLink>
          </li>
        </ul>
      </nav>
    
      <LanguageSwitcher></LanguageSwitcher>
    </template>
    
    <script>
      import LanguageSwitcher from "@/components/LanguageSwitcher.vue"
    
      export default {
        components: { LanguageSwitcher }
      }
    </script>

    Finally, open the src/i18n/translation.js file and add the following lines of code:

    import i18n from "@/i18n" // <--- 1
    
    const Trans = {  
      // ...
    
      set currentLocale(newLocale) { // <--- 2
        i18n.global.locale.value = newLocale
      },
    
      async switchLanguage(newLocale) { // <--- 3
        Trans.currentLocale = newLocale
        document.querySelector("html").setAttribute("lang", newLocale)
      },
      // ...
    }

    Here are the main points:

    1. Make sure to import the i18n object.
    2. currentLocale is a setter that switches the locale globally.
    3. switchLanguage accepts the newly chosen locale and uses the setter. It also adjusts the lang attribute for the html tag.

    Great job! Now you can open your app and use the switcher component to change the currently set language. We can do even better though, so let’s jump to the next section.

    Downloading translation files from Lokalise

    Once you have edited or added new translations on Lokalise, download them back into your project by running:

    lokalise2 file download --unzip-to PROJECT_PATH\src\i18n\locales --format json --token YOUR_TOKEN --project-id PROJECT_ID

    Persisting the user’s chosen locale

    There are two more issues with our app:

    1. We always use the English locale by default, without checking the user’s preference first.
    2. We do not store the user’s preferred locale once the language is switched.

    We’ll actually start with the second issue and persist the chosen locale by saving it into the local storage. Modify the src/i18n/translation.js file as shown here:

    const Trans = {
      // ... other code ...
    
      async switchLanguage(newLocale) {
        Trans.current_locale = newLocale
        document.querySelector("html").setAttribute("lang", newLocale)
        localStorage.setItem("user-locale", newLocale) // <--- add this
      },
    }
    
    export default Trans

    “Guessing” the preferred locale

    Now we need to try and “guess” the user’s preferred locale using the following approach:

    • If the preferred locale is found in the local storage and this locale is actually supported by the app, switch to this language.
    • Otherwise, read the preferred language sent by the browser (detect browser language).
    • If the preferred language is not available or is not supported, revert to the default locale, which is English in our case.

    Let’s add three new methods into the same translation.js file:

    const Trans = {
      isLocaleSupported(locale) { // <--- 1
        return Trans.supportedLocales.includes(locale)
      },
    
      getUserLocale() { // <--- 2
        const locale = window.navigator.language ||
          window.navigator.userLanguage ||
          Trans.defaultLocale
    
        return {
          locale: locale,
          localeNoRegion: locale.split('-')[0]
        }
      },
      
      getPersistedLocale() { // <--- 3
        const persistedLocale = localStorage.getItem("user-locale")
    
        if(Trans.isLocaleSupported(persistedLocale)) {
          return persistedLocale
        } else {
          return null
        }
      },
    
      // ...
    }
    
    export default Trans

    Here are the important things to note:

    1. This method checks whether the locale is present in the list of all the available locales.
    2. Inside the getUserLocale(), we read the preferred language sent by the browser. Next, we return this language in two “flavors”: with and without the region code. Thing is, locales might contain the region part written in the following way: en-US, ru-RU, fr-BE, and so on. The region part represents a country or region where the language is spoken because sometimes the differences are quite significant (compare US English to British English, for instance). In our case, the supported locales do not contain the region part but who knows what the future will bring. To cover both scenarios, we’ll compare the preferred language with and without the region code to understand whether we can support it or not.
    3. This method reads the local storage and checks whether the persisted locale is supported by the app.

    Finally, add the method to set the proper locale based on all the gathered information:

    const Trans = {
      guessDefaultLocale() {
        const userPersistedLocale = Trans.getPersistedLocale()
        if(userPersistedLocale) {
          return userPersistedLocale
        }
    
        const userPreferredLocale = Trans.getUserLocale()
    
        if (Trans.isLocaleSupported(userPreferredLocale.locale)) {
          return userPreferredLocale.locale
        }
    
        if (Trans.isLocaleSupported(userPreferredLocale.localeNoRegion)) {
          return userPreferredLocale.localeNoRegion
        }
        
        return Trans.defaultLocale
      },
      // ...
    }
    
    export default Trans

    Here we are simply trying to find a locale that is supported by the app, otherwise we revert to the defaultLocale. Let’s add this method now:

    const Trans = {
      get defaultLocale() {
        return import.meta.env.VITE_DEFAULT_LOCALE
      },
      // ...
    }
    
    // ...

    Nothing complex here.

    Seeing it in action

    To see this new feature in action, try changing the locale using the switcher, then open your debugger, and view the locale storage contents:

    There’s a new problem, however: even though we have a method to “guess” a locale… we never actually called it anywhere! But don’t worry, we’ll fix that in a moment.

    Locale code in the page URL

    Vue Router language prefix

    To properly handle setting the preferred locale, first I’d like to modify our routes so that the current locale is displayed in the URL.

    Open the src/routes/index.js file and make the following changes:

    import { createRouter, createWebHistory, RouterView } from 'vue-router' // <--- 1
    import HomeView from '../views/HomeView.vue'
    
    const router = createRouter({
      history: createWebHistory(import.meta.env.VITE_BASE_URL),
      routes: [
        {
          path: "/:locale?",  // <--- 2
          component: RouterView,  // <--- 3
          children: [  // <--- 4
            {
              path: '', // <--- 5
              name: 'home',
              component: HomeView
            },
            {
              path: 'about',  // <--- 6
              name: 'about',
              component: () => import('../views/AboutView.vue')
            }
          ]
        }
      ]
    })
    
    export default router
    

    Important points to note:

    1. Make sure to import the RouterView.
    2. Add a new parent path. The :locale is an optional parameter so we add the ? postfix. It means that all of the following routes are correct: /en/about, /ru/, /about. If the locale is not explicitly set, we’ll try to “guess” it using the method defined previously.
    3. We will use the RouterView to display our views.
    4. All other routes will be within the children property.
    5. This path changes from '/' to just '' because it’s a child route.
    6. Same change here; remove the forward slash.

    Having made these changes, let’s proceed to the next section.

    Adding the beforeEnter hook to support Vue 3 i18n

    Now the locale code can be part of the URL, but we don’t do anything with it. Thus, let’s introduce the beforeEnter hook in the src/routes/index.js file:

    import { createRouter, createWebHistory, RouterView } from 'vue-router'
    import HomeView from '../views/HomeView.vue'
    import Tr from "@/i18n/translation" // <--- add this
    
    const router = createRouter({
      history: createWebHistory(import.meta.env.VITE_BASE_URL),
      routes: [
        {
          path: "/:locale?",
          component: RouterView,
          beforeEnter: Tr.routeMiddleware,  // <--- add this
          children: [
            // ...
          ]
        }
      ]
    })
    
    export default router
    

    The actual middleware is defined inside the src/i18n/translation.js file:

    import i18n from "@/i18n"
    
    const Trans = {
      // ...
    
      async routeMiddleware(to, _from, next) {
        const paramLocale = to.params.locale
    
        if(!Trans.isLocaleSupported(paramLocale)) {
          return next(Trans.guessDefaultLocale())
        }
    
        await Trans.switchLanguage(paramLocale)
    
        return next()
      },
    }
    
    export default Trans

    Here we are reading the locale code from the URL and checking if it’s supported by the app. If not, we “guess” the locale and redirect the user to the corresponding route. If it is supported, we switch to it and proceed to the requested page.

    To test this feature, try changing the locale code directly within the URL. For example, try entering en, ru, an unsupported locale, and no locale at all: every case should be properly handled. Nice — now we know how to change the language dynamically in our app!

    Rewriting the URL on locale switch

    There’s one last thing that we should take care of. Currently, when the user switches the locale the URL is not being updated. In other words, if you visit the /en/about page and switch the language to Russian, the URL won’t update to /ru/about, which is not good. Let’s deal with this issue as well!

    Open the src/components/LanguageSwitcher.vue file and make the following changes to the script:

    <script>
      import { useI18n } from 'vue-i18n'
      import { useRouter } from "vue-router" // <--- 1
      import Tr from "@/i18n/translation"
    
      export default {
        setup() {
          const { t, locale } = useI18n()
    
          const supportedLocales = Tr.supportedLocales
    
          const router = useRouter()  // <--- 2
    
          const switchLanguage = async (event) => {
            const newLocale = event.target.value
    
            await Tr.switchLanguage(newLocale)
    
            try {
              await router.replace({ params: { locale: newLocale } })  // <--- 3
            } catch(e) {  // <--- 4
              console.log(e)
              router.push("/")
            }
          }
    
          return { t, locale, supportedLocales, switchLanguage }
        }
      }
    </script>

    Main things to note:

    1. Import the Vue Router.
    2. Store the router inside the constant.
    3. Replace our URL to the history, and add the requested locale.
    4. If something goes wrong, redirect the user to the root page.

    Try switching the language using the dropdown and note that the URL is properly updated. Cool, right?

    Displaying the locale in the router links

    If you think our job is done, you’re mistaken. But brace yourself, we’re nearly there. Another thing that I wanted to fix is our router links in the src/components/Nav.vue that do not contain language codes in the generated links. Of course, we can simply use the locale object and interpolate the locale’s value, but let’s use a slightly different approach.

    Open the src/i18n/translation.js file and add a new helper function and a getter:

    import i18n from "@/i18n"
    
    const Trans = {
      get currentLocale() {
        return i18n.global.locale.value
      },
    
      i18nRoute(to) {
        return {
          ...to,
          params: {
            locale: Trans.currentLocale,
            ...to.params
          }
        }
      }
    
      // ...
    }
    
    export default Trans

    This helper can be used to build the router links. Open the src/components/Nav.vue and make the following changes:

    <template>
      <nav>
        <ul>
          <li>
            <RouterLink :to="Tr.i18nRoute({ name: 'home' })">{{ $t("nav.home") }}</RouterLink> <-- 1 -->
          </li>
          
          <li>
            <RouterLink :to="Tr.i18nRoute({ name: 'about' })">{{ $t("nav.about") }}</RouterLink> <-- 2 -->
          </li>
        </ul>
      </nav>
    
      <LanguageSwitcher></LanguageSwitcher>
    </template>
    
    <script>
      import LanguageSwitcher from "@/components/LanguageSwitcher.vue"
      import Tr from "@/i18n/translation" // <--- 3
    
      export default {
        components: { LanguageSwitcher },
        setup() {  // <--- 4
          return { Tr } 
        }
      }
    </script>
    

    Main points to note:

    1. We’re using the helper function and the named route to build a proper link.
    2. Same change here, use the about route. Make sure to use the proper name as defined in your routes.
    3. Import the Tr object.
    4. Export the Tr object.

    Now the links contain the locale!

    Lazily loading Vue 3 translation files

    The very last feature that we’re going to implement today is lazy loading for our translation files. As previously explained, it makes little sense to import all the translations when the app is loaded because most likely the user won’t need all of them. Instead, let’s load the necessary files when the language is switched. To achieve this, modify the src/i18n/translation.js file:

    import i18n from "@/i18n"
    import { nextTick } from "vue" // <--- 1
    
    const Trans = {
      async switchLanguage(newLocale) {
        await Trans.loadLocaleMessages(newLocale)  // <--- 2
        Trans.currentLocale = newLocale
        document.querySelector("html").setAttribute("lang", newLocale)
        localStorage.setItem("user-locale", newLocale)
      },
    
      async loadLocaleMessages(locale) {
        if(!i18n.global.availableLocales.includes(locale)) {  // <--- 3
          const messages = await import(`@/i18n/locales/${locale}.json`)  // <--- 4
          i18n.global.setLocaleMessage(locale, messages.default)  // <--- 5
        }
        
        return nextTick()  // <--- 6
      },
    
      // ...
    }
    
    export default Trans

    Main things to note:

    1. Import the nextTick function.
    2. Loading of translation files before the locale is switched.
    3. Check whether the files have already been loaded previously.
    4. If not, import the necessary JSON file.
    5. Use the loaded messages.
    6. Return nextTick saying that the translation data is ready to be used.

    Also, open the src/i18n/index.js file and modify it like so:

    import { createI18n } from "vue-i18n"
    import pluralRules from "./rules/pluralization"
    import numberFormats from "./rules/numbers.js"
    import datetimeFormats from "./rules/datetime.js"
    import en from "./locales/en.json"
    // remove ru.json import!
    
    
    
    export default createI18n({
      locale: import.meta.env.VITE_DEFAULT_LOCALE,
      fallbackLocale: import.meta.env.VITE_FALLBACK_LOCALE,
      legacy: false,
      globalInjection: true,
      messages: { en }, // <--- use only the English messages
      pluralRules,
      numberFormats,
      datetimeFormats
    })

    As you can see, we’ve removed the ru.json import as it should be loaded on demand. Additionally, the messages attribute now contains only the English translations because this is our default locale.

    To test this feature, open the English version of the app by navigating to localhost:5173/en. Then open your debugger and switch to the Russian language. You should see the ru.json file being loaded under the Network tab:

    Awesome! At this point our app is translated into two languages, it has a language switcher and locale detection, as well as a translations lazy loading feature.

    Conclusion

    So, in this article we have seen how to perform Vue 3 internationalization. We have discussed the basic features of the I18n plugin, we’ve introduced language switching, added language detection, modified our routes so that the locale is persisted, and also added the lazy loading functionality.

    Thank you for staying with me, and until the next time!

    Further reading

    Talk to one of our localization specialists

    Book a call with one of our localization specialists and get a tailored consultation that can guide you on your localization path.

    Get a demo

    Related posts

    Learn something new every two weeks

    Get the latest in localization delivered straight to your inbox.

    Related articles
    Localization made easy. Why wait?
    The preferred localization tool of 3000+ companies