Software localization

A Comprehensive Guide to Vue Localization

Dive into Vue localization and learn how to plug the Vue I18n library into your app, so you can make it accessible to a global user base.
Vue localization blog post featured image | Phrase

Arguably the most approachable among the big three UI frameworks, Evan You’s Vue seems an unlikely contender among the giant Meta’s React and Google’s Angular. Yet this brainchild of one man has seen adoption that matches Angular’s, thanks to its gentle learning curve, first-class dev experience, and production-ready features.

With its popularity, Vue has given birth to a rich ecosystem of plugins, extensions, and services. Vue app internationalization (i18n) — presumably the reason you’re here — sees the robust third-party Vue I18n plugin as the apparent go-to. In this hands-on guide, we’ll use Vue I18n to internationalize a little demo app, covering everything you need to get started with Vue localization. Let’s go.

Heads up » This article covers Vue 3 localization. If you’re interested in Vue 2, check out Vue 2 Localization with Vue I18n: A Step-by-Step Guide.

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

Library versions used

We’ve used the following NPM packages in this article (versions in parentheses).

  • Vue (3.2) — our UI framework
  • Vue router (4.1) — the official Vue SPA router
  • Vue I18n (9.2) — Vue’s third-party go-to i18n library
  • Tailwind CSS (3.1) — used for styling and optional for our purposes

🗒 Note » To focus on the i18n, we won’t show any CSS styling in this article. You can find all styling code in the full code of our article on GitHub.

Our demo

Our humble demo, Mushahed, is based on data from the Open Notify space APIs.

Our demo app celebrates the world’s brave astronauts

Our demo app celebrates the world’s brave astronauts

Attributions

Shout outs to the following people and organizations for providing their assets for free.

Our demo is a Vue SPA spun up with npm init vue@latest. We added router support and opted out of TypeScript when the project was being scaffolded. After the requisite gutting of the boilerplate components added by the scaffolding tool, we built this little hierarchy:

.

└── src/

    ├── components/

    │   ├── AstroCard.vue

    │   ├── Astronauts.vue

    │   ├── Coords.vue

    │   ├── Footer.vue

    │   └── Nav.vue

    ├── router/

    │   └── index.js

    ├── views/

    │   ├── HomeView.vue

    │   └── AboutView.vue

    └── App.vue



Our demo's component breakdown
Our demo's component breakdown

🗒 Note » Our App houses a Vue <RouterView> and we’re using <RouterLink>s in our nav. We’ll look at routing a bit later.

Let’s take a closer look at our <Astronauts> component.

<script>

import AstroCard from './AstroCard.vue'



export default {

  components: { AstroCard },



  data() {

    return {

      loading: true,

      astros: [],

    }

  },



  created() {

    fetch('/data/astronauts.json')

      .then((res) => res.json())

      .then((data) => {

        this.astros = data

        this.loading = false

      })

  },

}

</script>



<template>

  <!-- No i18n: Hard-coded English -->

  <p v-if="loading">Loading...</p>



  <div v-else>

    <div>

      <h2>

        <!-- No i18n: Hard-coded plural -->

        🧑‍🚀 {{ astros.length }} people in space

      </h2>



      <p>

       <!-- No i18n: Hard-coded date -->

        Updated Jul 26, 2022

      </p>

    </div>



    <div>

      <AstroCard

        v-for="astro in astros"

        :key="astro.id"

        :name="astro.name"

        :nationality="astro.nationality"

        :craft="astro.craft"

        :photoUrl="astro.photoUrl"

      />

    </div>

  </div>

</template>



When <Astronauts> is created, we load our astronaut data from public/data/astronauts.json and feed it to instances of the presentational <AstroCard>.

[

  // ...



  {

    "id": 7,

    "name": "Jessica Watkins",

    "photoUrl": "j-watkins.jpg",

    "nationality": "USA 🇺🇸",

    "craft": "ISS"

  },



  // ...

]



Our <AstroCard> instances rendering astronaut data
Our <AstroCard> instances rendering astronaut data

Note that our UI strings are all hard-coded in English at this point. Let’s take care of this and localize our app.

🔗 Resource » We’re omitting much of our demo starter code for brevity. You can get all of it from the start-options branch of our GitHub repo.

How do I install and set up Vue I18n?

This will shock you: We start with an NPM install on the command line from the root of our Vue project.

$ npm install vue-i18n@9 



🗒 Note » You’ll want v9+ of Vue I18n if you’re working with Vue 3. Vue 2 uses Vue i18n v8.

🔗 Resource » Check out all the ways to install Vue I18n in the official documentation.

Once NPM has done its thing, we need to create a Vue I18n instance, configure it, and register it as a plugin with our Vue instance. Let’s construct the Vue I18n instance in a new module. We’ll create a directory called src/i18n and place an index.js file within.

import { createI18n } from 'vue-i18n'



const i18n = createI18n({

  // default locale

  locale: 'en',



  // translations

  messages: {

    en: {

      appTitle: 'Mushahed',

    },

    ar: {

      appTitle: 'مشاهد',

    },

  },

})



export default i18n



We pass our translation messages to the i18n object that we construct with createI18n(). The initial locale, the one our app defaults to on first load, is set via the locale config option.

🗒 Note » I’m supporting English (en) and Arabic (ar) in my app. Feel free to support any languages you want here. Use a standard BCP 47 language tag (like en) or a language tag with a region subtag (like en-US) to identify your translation locales.

🔗 Resource » All config options for createI18n() are available in the official API documentation.

Our Vue instance now needs to register our i18n object as a plugin with a use() call.

import { createApp } from 'vue'

import App from './App.vue'

import router from './router'

import i18n from './i18n'



import './assets/main.css'



const app = createApp(App)



app.use(i18n)

app.use(router)



app.mount('#app')



That should round out our setup. Let’s test our i18n by internationalizing the app title in our <Nav> component.

<script setup>

import { RouterLink } from 'vue-router'

</script>



<template>

  <nav>

    <img alt="Mushahed logo" src="@/assets/logo.svg" />



    <!-- App title is hard-coded in English -->

    <span>Mushahed</span>



    <RouterLink to="/">Home</RouterLink>

    <RouterLink to="/about">About</RouterLink>

  </nav>

</template>



We'll replace the hard-coded text with the following.

<script setup>

import { RouterLink } from 'vue-router'

</script>



<template>

  <nav>

    <!-- ... -->



    <span>{{ $t('appTitle') }}</span>



    <!-- ... -->

  </nav>

</template>



Available to all our components now is Vue I18n’s $t() translation function. Calling $t('appTitle') when the active locale is English (en) will cause $t() to return the message we specified at messages.en.appTitle above. When the active locale is Arabic (ar), messages.ar.appTitle is returned.

🤿 Go deeper » Check out the myriad ways to use $t() in the official API listing.

If we reload our app now we should see no change: That’s because our initial locale is configured to English. Let’s switch it to Arabic.

import { createI18n } from 'vue-i18n'



const i18n = createI18n({

  locale: 'ar',

  messages: {

	en: {

      appTitle: 'Mushahed',

    },

    ar: {

      appTitle: 'مشاهد',

    },

  },

})



export default i18n



Et voilà!

Our app name translated to Arabic

Our app name translated to Arabic

That’s all it takes to start working with Vue I18n in our apps. Of course, we probably want to keep adding translation messages as we grow our app. To keep things tidy, let’s refactor our messages object to its own module.

export default {

  en: {

    appTitle: 'Mushahed',

  },

  ar: {

    appTitle: 'مشاهد',

  },

}



import { createI18n } from 'vue-i18n'

import messages from './messages'



const i18n = createI18n({

  locale: 'ar',

  messages,

})



export default i18n



🗒 Note » When developing with Vue I18n, you might get a warning in your browser console that says, “You are running the esm-bundler build of vue-i18n…”. This is a known issue and may be fixed by the time you read this.

How do I translate messages in my components?

We touched on this when we tested our Vue I18n installation above, but it bears repeating. It takes two steps:

  1. In our messages object, under each of our locales, we add translations with a shared key.
  2. We use $t(key) in our component templates to render the translation corresponding to the active locale.

Let’s apply this by localizing the rest of our <Nav> component. We'll need a few more translations to start.

export default {

  en: {

    // Use same keys as ar

    appTitle: 'Mushahed',

    logo: 'Mushahed logo',

    home: 'Home',

    about: 'About',

  },

  ar: {

    // Use same keys as en

    appTitle: 'مشاهد',

    logo: 'رمز مشاهد',

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

    about: 'نبذة عنا',

  },

}



<script setup>

import { RouterLink } from 'vue-router'

</script>



<template>

  <nav>

    <img :alt="$t('logo')" src="@/assets/logo.svg" />

    <span>{{ $t('appTitle') }}</span>



    <RouterLink to="/">{{ $t('home') }}</RouterLink>

    <RouterLink to="/about">{{ $t('about') }}</RouterLink>

  </nav>

</template>



We can use $t() with the {{ }} syntax to translate the inner text of an element. When translating an attribute, the :attribute binding shorthand comes in handy.

Our app name translated to Arabic

Our navigation component when Arabic is the active locale

How do I work with dynamic values in my translation messages?

A common use case, interpolating values that change at runtime in our messages is easy with Vue I18n. Our <Coords> component, which shows the coordinates of the International Space Station (ISS) at a given time, is a perfect place to demonstrate.

<script>

export default {

  data() {

    return {

      coords: {

        latitude: 49.5908,

        longitude: 122.8927,

      },

      datetime: new Date(1658828129000),

    }

  },

}

</script>



<template>

  <p>

    The ISS was over {{ coords.latitude }}° N, {{ coords.longitude }}° E on {{ datetime }}

  </p>

</template>



Hard-coded English interpolation

We’ve hard-coded the coordinate and datetime values above for clarity, but in a real app these would likely be fetched from an API and updated on component created. Vue I18n accommodates these dynamic values in its messages via a {placeholder} syntax.

export default {

  en: {

    // ...

    issPosition: 'The ISS was over {latitude}° N, {longitude}° E on {datetime}',

  },

  ar: {

    // ...

    issPosition:

      'كانت محطة الفضاء الدولية فوق {latitude} درجة شمالا و {longitude} درجة شرقا يوم {datetime}',

  },

}



<script>

export default {

  data() {

    return {

      coords: {

        latitude: 49.5908,

        longitude: 122.8927,

      },

      datetime: new Date(1658828129000),

    }

  },

}

</script>



<template>

  <p>

    {{ $t('issPosition', {

        latitude: coords.latitude,

        longitude: coords.longitude,

        datetime,

      }) }}

  </p>

</template>



Passing a second argument to $t() — a map of key/value pairs where the keys match the ones in our translation messages — renders these messages with the injected values.

An Arabic message with interpolated dynamic values

An Arabic message with interpolated dynamic values

🗒 Note » The numbers and date above are not in Arabic. We’ll take care of this a bit later.

🤿 Go deeper » Learn all the ways you can interpolate in messages from the official Vue I18n documentation.

How do I translate strings in my component JavaScript?

The $t() function is available to our component JavaScript via this.$t(). Let’s use this to refactor our <Coords> component and move that chunky $t() call to our component <script>.

<script>

export default {

  data() {

    return {

      coords: {

        latitude: 49.5908,

        longitude: 122.8927,

      },

      datetime: new Date(1658828129000),

    }

  },



  computed: {

    issPosition() {

      return this.$t('issPosition', {

        latitude: this.coords.latitude,

        longitude: this.coords.longitude,

        datetime: this.datetime,

      })

    },

  },

}

</script>



<template>

  <p>{{ issPosition }}</p>

</template>



How do I work with HTML within my translation messages?

On occasion, we will need to place HTML inside our translation messages. Our <Footer> is a good example.

<template>

  <p>

    Created with

    <a href="https://vuejs.org/">Vue</a> for a

    <a href="https://phrase.com/blog">Phrase blog</a>

    tutorial.

  </p>

</template>



Localizing this string is tricky because the locations of the embedded links can differ depending on the translation language. We could just place the <a> tags directly in our translation messages and exploit Vue’s unsafe v-html directive to output the translations. This would expose us to XSS attacks if we’re not careful, however.

Vue I18n offers a better solution: Its <i18n-t> component allows us to render its children, including HTML elements, inside our messages via placeholders.

export default {

  en: {

    // ...

    footer: 'Created with {0} for a {1}.',

    vue: 'Vue',

    phraseBlogTutorial: 'Phrase blog tutorial',

  },

  ar: {

    // ...

    footer: '.تم إنشائه بواسطة {0} ليصاحب {1}',

    vue: 'ڤيو',

    phraseBlogTutorial: 'درس على مدونة فريز',

  },

}



<script>

export default {

  data() {

    return {

      vueUrl: 'https://vuejs.org/',

      phraseBlogUrl: 'https://phrase.com/blog',

    }

  },

}

</script>



<template>

  <i18n-t keypath="footer" tag="p" scope="global">

    <a :href="vueUrl">{{ $t('vue') }}</a>

    <a :href="phraseBlogUrl">{{ $t('phraseBlogTutorial') }}</a>

  </i18n-t>

</template>



We pass <i18n-t> a keypath prop with the key of our parent translation message, footer in this case. For rendering, we let <i18n-t> know that we want it to output a surrounding <p> via the tag prop.

Within our parent message, we specify placeholders using list interpolation, meaning we index our placeholders starting with {0} and moving on to {1}, etc. Order matters here: The first <a> inside <i18n-t> will replace the {0} placeholder, the second will replace {1}, and so on. This allows us to control the order our HTML elements appear in each language’s translation message.

🗒 Note » If we don’t explicitly set the scope="global" prop on the <i18n-t> component we will get a console warning reading, “[intlify] Not found parent scope. use the global scope.”

🔗 Resource » The Component Interpolation section of the official guide covers the <i18n-t> component in further detail.

How do I work with plurals in my translations?

The two English plural forms are simple: “a satellite is orbiting above”; “three satellites are orbiting above”. Other languages are more complex. Some have four plural forms. Welsh and Arabic have six. Vue I18n handles simple plurals, like those of English, out of the box. We can extend the plugin to handle complex plurals. We’ll cover both simple and complex plurals here.

🔗 Resource » The CLDR Language Plural Rules reference is canon for languages’ plural forms.

Let’s revisit the header of our <Astronauts> component for a moment.

An astronaut counter

An astronaut counter

<script>

// We populate the astros array here...

</script>

<template>

  <div>

    <div>

      <h2>🧑‍🚀 {{ astros.length }} people in space</h2>



      <p>Updated Jul 26, 2022</p>

    </div>



    <!-- ... -->

  </div>

</template>



Our astronaut counter is hard-coded and ripe for localization. Let’s add an English message for it.

export default {

  en: {

    // ...

    peopleInSpace:

      '{n} person in space | {n} people in space',

  },

  ar: {

    // ...

  }

}



Vue I18n expects plural forms to be separated by a pipe (|) character. We’ve specified the two plural forms for English above. The {n} placeholder will be replaced by an integer counter when we retrieve the plural message.

<script>

// We populate the astros array here...

</script>

<template>

  <div>

    <div>

      <h2>🧑‍🚀 {{ $tc('peopleInSpace', astros.length) }}</h2>



      <p>Updated Jul 26, 2022</p>

    </div>



    <!-- ... -->

  </div>

</template>



$tc(), another translation function injected by Vue i18n into all of our components, chooses the correct plural form based on its second parameter, the integer counter.

Renders of English plural forms. Note that {n} is replaced with our counter.

Renders of English plural forms. Note that {n} is replaced with our counter.

Two forms work fine for English, but our Arabic translation will need six plural variants.

export default {

  en: {

    // ...

  },

  ar: {

    // ...

    peopleInSpace:

      'لا يوجد أحد في الفضاء | يوجد شخص {n} في الفضاء | يوجد شخصان في الفضاء | توجد {n} أشخاص في الفضاء | يوجد {n} شخص في الفضاء | يوجد {n} شخص في الفضاء',

  },

}



On its own Vue I18n only works with English-like plurals, so we need to add a custom extension function that handles Arabic’s six forms.

export function arabicPluralRules(choice) {

  const name = new Intl.PluralRules('ar').select(choice)



  return { zero: 0, one: 1, two: 2, few: 3, many: 4, other: 5 }[name]

}



JavaScript’s standard Intl.PluralRules object handles complex plurals wonderfully. All we have to do is give it a locale when constructing it, then call its select() method with an integer counter. The method returns the name of the correct form for the given language. For example, new Intl.PluralRules('ar').select(5) returns the correct few form.

Vue I18n needs an integer index to select the correct form in our translation messages, so our custom plural selector needs to map the CLDR plural form name (few) to a zero-based index (3). The index selects from our pipe-separated message. So 3 would select our fourth variant from the peopleInSpace message above.

All we have to do now is wire up our Arabic plural rule selector when constructing the Vue I18n instance.

import { createI18n } from 'vue-i18n'

import messages from './messages'

import { arabicPluralRules } from './plurals'



const i18n = createI18n({

  locale: 'ar',

  messages,

  // Vue I18n allows us to extend its plural

  // formatting by providing one form selector

  // function per locale

  pluralizationRules: {

    ar: arabicPluralRules,

  },

})



export default i18n



With our selector wired up, our Arabic plurals should work like a charm.

Renders of Arabic plural forms. Note that {n} is replaced with our counter.

Renders of Arabic plural forms. Note that {n} is replaced with our counter.

Heads up » You may have noticed that the interpolated counter is being displayed in Western Arabic numerals (1, 2, etc.). However, Arabic uses Eastern Arabic numerals (١، ٢، ٣، etc.). While not a showstopper, I have logged this issue on the Vue i18n GitHub if you care to follow it.

🔗 Resource » Learn more from the official guide on pluralization.

How do I format localized numbers?

Different locales use different numeral systems, thousands separators, and symbols when representing numbers. JavaScript’s built-in Intl.NumberFormat object handles all this for us and is used under the hood by Vue I18n. We just need to give Vue I18n preconfigured number formats, which the plugin in turn passes to Intl.NumberFormat. The formats we registered are then available to use in our components.

🤿 Go deeper » Our Concise Guide to Number Localization covers numeral systems, separators, and more.

// We specify the formats our app will use

export const numberFormats = {

  'en-US': {

    // A named format

    coords: {

      // These options are passed to Intl.NumberFormat

      style: 'decimal',

      minimumSignificantDigits: 6,

      maximumSignificantDigits: 6,

    },

  },

  'ar-EG': {

    coords: {

      style: 'decimal',

      minimumSignificantDigits: 6,

      maximumSignificantDigits: 6,

    },

  },

}



We need to register our number formats with the Vue I18n object during construction.

import { createI18n } from 'vue-i18n'

import messages from './messages'

import { numberFormats } from './numbers'

import { arabicPluralRules } from './plurals'



const i18n = createI18n({

  locale: 'en-US',

  messages,

  numberFormats,

  pluralizationRules: {

    'ar-EG': arabicPluralRules,

  },

})



export default i18n



Now we can use the injected $n() function to format localized numbers in our component templates.

<!-- We specify the named format as the second param -->

<p>{{ $n(49.5908, 'coords') }}</p>



<!-- => "49.5908" when locale is en-US -->

<!-- => "٤٩،٥٩٠٨" when locale is ar-EG -->



Heads up » You may have noticed that we swapped en with en-US and ar with ar-EG in our configuration above. This is because number formatting is region-specific, not language-specific. Adding countries or regions to our locale tags means we can control the output of localized number formatting. Otherwise, we risk the browser using a default region. Of course, we have to update our messages so that they’re keyed with en-US and ar-EG as well.

export default {

  'en-US': {

    appTitle: 'Mushahed',

    // ...

  },

  'ar-EG': {

    appTitle: 'مشاهد',

    // ...

  },

}



Let’s update our <Coords> component to format the ISS coordinates in the active locale’s number format.

<script>

export default {

  data() {

    return {

      coords: null,

      datetime: '',

    }

  },



  created() {

    // We fetch the coordinate data and set

    // this.coords and this.datetime here...

  },



  computed: {

    issPosition() {

      return this.$t('issPosition', {

        latitude: this.$n(this.coords.latitude, 'coords'),

        longitude: this.$n(this.coords.longitude, 'coords'),

        datetime: this.datetime,

      })

    },

  },

}

</script>



<template>

  <p>{{ issPosition }}</p>

</template>



Renders of Arabic plural forms. Note that {n} is replaced with our counter.

Renders of Arabic plural forms. Note that {n} is replaced with our counter.

🔗 Resource » Get more details from the Number Formatting section of the Vue I18n documentation.

The date above looks very English in the otherwise Arabic message, doesn’t it? No worries. Guess what’s next?

How do I format localized dates and times?

Much like number formatting, date formatting is region-specific. The US and Canada both use English, but the 9th of September, 2022 can be 9/4/2022 in the US and 2022-09-04 in Canada. To work with localized dates correctly, we follow a recipe much like we did with dates. We provide Vue I18n with named datetime formats, which the plugin passes as options to the standard Intl.DateTimeFormat. We then use these registered formats in our components.

🗒 Note » Date localization is very similar to number localization, so this section builds on the last.

export const datetimeFormats = {

  'en-US': {

    full: {

      // These options are passed to Intl.DateTimeFormat

      dateStyle: 'full',

      timeStyle: 'full',

    },

    short: {

      year: 'numeric',

      month: 'short',

      day: 'numeric',

    },

  },

  'ar-EG': {

    full: {

      dateStyle: 'full',

      timeStyle: 'full',

    },

    short: {

      year: 'numeric',

      month: 'long',

      day: 'numeric',

    },

  },

}



import { createI18n } from 'vue-i18n'

import messages from './messages'

import { numberFormats } from './numbers'

import { datetimeFormats } from './datetimes'

import { arabicPluralRules } from './plurals'



const i18n = createI18n({

  locale: 'en-US',

  messages,

  numberFormats,

  datetimeFormats,

  pluralizationRules: {

    'ar-EG': arabicPluralRules,

  },

})



export default i18n



With formats specified and registered, we can use the injected $d() function to display localized dates in our components. Let’s round out our <Coords> component with proper date formatting.

<script>

export default {

  // ...



  computed: {

    issPosition() {

      return this.$t('issPosition', {

        latitude: this.$n(this.coords.latitude, 'coords'),

        longitude: this.$n(this.coords.longitude, 'coords'),

        datetime: this.$d(this.datetime, 'full'),

      })

    },

  },

}

</script>



<template>

  <p>{{ issPosition }}</p>

</template>



American English and Egyptian Arabic full date formats

American English and Egyptian Arabic full date formats

While we’re at it, let’s format our <Astronauts> header to show localized short dates in its “Updated” message.

export default {

  'en-US': {

    // ...

    updatedAt: 'Updated {date}',

  },

  'ar-EG': {

    // ...

    updatedAt: 'أخر تحديث {date}',

  },

}



<script>

// ...

</script>

<template>

  <!-- ... -->

  <h2>🧑‍🚀 {{ $tc('peopleInSpace', astros.length) }}</h2>

  <p>

    {{ $t('updatedAt', { date: $d(updated, 'short') }) }}

  </p>

</template>



American and Egyptian short dates rendered

American and Egyptian short dates rendered

🔗 Resource » The official Vue I18n guide covers more date formatting options.

How do I retrieve the active locale?

Sometimes we need to make decisions based on the runtime locale of the app. With Vue I18n we can get the active locale simply via i18n.locale.

<script>

export default {

  methods: {

    activeLocale() {

      return this.$i18n.locale

    }

  }

}

</script>



<template>

  <!-- Assuming active locale is en-US -->

  <p>{{ $i18n.locale}}</p> <!-- => <p>en-US</p> -->



  <p>{{ activeLocale() }}</p> <!-- => <p>en-US</p> -->

</template>



We can also assign a new value to $i18n.locale to set a new active locale. We’ll see this in action momentarily.

Refactoring the i18n library

In the next few sections we’ll tackle some advanced topics like localized routes and asynchronous translation file loading. These will be easier to implement if we refactor our little i18n library so that we can control how locales are set and loaded.

import { createI18n } from 'vue-i18n'

import { messages } from './messages'

import { numberFormats } from './numbers'

import { arabicPluralRules } from './plurals'

import { datetimeFormats } from './datetimes'



// Set and expose the default locale

export const defaultLocale = 'en-US'



// Private instance of VueI18n object

let _i18n



// Initializer

function setup(options = { locale: defaultLocale }) {

  _i18n = createI18n({

    locale: options.locale,

    fallbackLocale: defaultLocale,

    messages,

    numberFormats,

    datetimeFormats,

    pluralizationRules: {

      'ar-EG': arabicPluralRules,

    },

  })



  setLocale(options.locale)



  return _i18n

}



// Sets the active locale. 

function setLocale(newLocale) {

  _i18n.global.locale = newLocale

}



// Public interface

export default {

  // Expose the VueI18n instance via a getter

  get vueI18n() {

    return _i18n

  },

  setup,

  setLocale,

}



🗒️ Note » Vue I18n supports scoping which we can use to change the locale for a subset of our app’s component hierarchy. We use i18n.global to access Vue I18n’s global, app-wide scope. This is Vue I18n's default scope, and we’ll only work with global scope in this article.

Let’s see how the rest of our app changes based on this refactor.

import { createApp } from 'vue'

import App from './App.vue'

import router from './router'

import i18n from './i18n'

import './assets/main.css'



const app = createApp(App)



// Explicitly initialize the i18n library

i18n.setup()

// Pass the VueI18n instance as a plugin to use()

app.use(i18n.vueI18n)

app.use(router)



app.mount('#app') 



Nothing else in our app needs to change, yet our refactor will allow us to build more complex features in the following sections more easily.

How do I localize my routes?

It’s often a good idea to make sure that our URLs reflect the associated content. Localized URLs can mean that /en-US/foo and /ar-EG/foo point to the English and Arabic versions of the foo page, respectively. Let’s get this working in our demo app.

First, let’s take a look at how we’ve configured the routes in our demo.

import { createRouter, createWebHistory } from 'vue-router'

import HomeView from '../views/HomeView.vue'



const router = createRouter({

  history: createWebHistory(import.meta.env.BASE_URL),

  routes: [

    {

      path: '/',

      name: 'home',

      component: HomeView

    },

    {

      path: '/about',

      name: 'about',

      // Lazy-loaded via code-splitting

      component: () => import('../views/AboutView.vue')

    }

  ]

})



export default router



Our relatively simple setup has the / route loading our <HomeView> and /about loading our <AboutView>. The components are loaded inside a <router-view> in the root <App> component.

<script setup>

import { RouterView } from 'vue-router'

import Nav from './components/Nav.vue'

import Footer from './components/Footer.vue'

</script>



<template>

  <div>

    <header>

      <Nav />

    </header>



    <RouterView />



    <Footer />

  </div>

</template>



Let’s localize these routes so that /en-US/about shows the English About page and /ar-EG/about shows the Arabic About page.

import { createRouter, createWebHistory } from 'vue-router'

import { defaultLocale } from '../i18n'

import HomeView from '../views/HomeView.vue'



const router = createRouter({

  history: createWebHistory(import.meta.env.BASE_URL),

  routes: [

    // The root path always redirects to a

    // localized route

    {

      path: '/',

      redirect: `/${defaultLocale}`,

    },

    // All paths under the root are localized

    {

      path: '/:locale',

      children: [

        {

          // The empty path specifies the default

          // child route component

          path: '',

          component: HomeView,

        },

        {

          // Using the relative 'about' not the absolute

          // '/about' allows us to include the :locale

          // param from the parent.

          path: 'about',

          component: () => import('../views/AboutView.vue'),

        },

      ],

    },

  ],

})



export default router



🔗 Resource » The official Vue Router guide is a great start to learn the basics of Vue routing.

With these changes, if we now visit /, we will be redirected to /en-US (assuming en-US is our configured default locale). /en-US is our localized root route. It shows the <HomeView> in the <App>’s <router-view> by default. /en-US/about shows the <AboutView>.

However, if we visit /ar-EG , or any other Arabic route, we’re greeted with English translations. That’s because we’re not switching the active locale when the :locale route parameter changes. Let’s remedy this using a beforeEach router navigation guard.

import { createRouter, createWebHistory } from 'vue-router'

import i18n, { defaultLocale } from '../i18n'

// ...



const router = createRouter({

  // ...

})



router.beforeEach((to, from) => {

  const newLocale = to.params.locale

  const prevLocale = from.params.locale



  // If the locale hasn't changed, do nothing

  if (newLocale === prevLocale) {

    return

  }



  i18n.setLocale(newLocale)

})



export default router



The Vue router’s handy global beforeEach() guard runs before any navigation, ensuring that when the locale param in the URL changes we’ll know about it. We pass an anonymous callback to the guard, and use our new setLocale() function to update Vue I18n’s active locale when the locale param changes. This means that when we hit /ar-EG/about, we’ll see the Arabic version of the About page.

Our Arabic routes now show Arabic translations

Our Arabic routes now show Arabic translations

How do I build a reusable localized link component?

One problem with our current localized routing solution is that we would need to inject the :locale route parameter manually every time we create a router link.

<script setup>

import { RouterLink } from 'vue-router'

</script>



<template>

  <nav>

    <!-- ... -->

    <router-link :to="`/${$i18n.locale}`">

      {{ $t('home') }}

    </router-link>



    <router-link :to="`/${$i18n.locale}/about`">

      {{ $t('about') }}

    </router-link>

    </nav>

</template>



This doesn’t scale very well and is error-prone. Let’s DRY (Don’t Repeat Yourself) this up by wrapping Vue’s <router-link> in a custom component that handles route localization automatically.

<script>

import { RouterLink } from 'vue-router'



export default {

  // Expose the to prop to accept relative,

  // non-localized URIs

  props: ['to'],



  components: { RouterLink },



  computed: {

    localizedUrl() {

      // The root / route is special since it's

      // absolute

      return this.to === '/'

        ? `/${this.$i18n.locale}`

        : `/${this.$i18n.locale}/${this.to}`

    },

  },

}

</script>



<template>

  <!-- Internally, we're just using Vue's 

       good old router link -->

  <router-link :to="localizedUrl">

    <slot></slot>

  </router-link>

</template>



Our new <LocalizedLink> is almost a drop-in replacement for <router-link>s. We just need to be careful to use relative URLs for anything other than the root route.

<script setup>

import { RouterLink } from 'vue-router'

import LocalizedLink from './l10n/LocalizedLink.vue'

</script>



<template>

  <nav>

    <!-- ... -->



    <LocalizedLink to="/">

      {{ $t('home') }}

    </LocalizedLink>

    <!-- When active locale is ar-EG, renders to

         /ar-EG -->



    <!-- Notice that we point to about not /about -->

    <LocalizedLink to="about">

      {{ $t('about') }}

    </LocalizedLink>

    <!-- When active locale is ar-EG, renders to

         /ar-EG/about -->

  </nav>

</template>



How do I build a language switcher UI?

To allow our site visitors the ability to select their locales, let’s build a language switcher dropdown component that makes use of our localized routes. First, we’ll configure and expose our app’s supported locales in our i18n library.

// ...



// Using a { localeCode: localeData } structure

// allows us to add metadata, like a name, to each

// locale as our needs grow.

export const supportedLocales = {

  'en-US': { name: 'English' },

  'ar-EG': { name: 'العربية (Arabic)' },

}



// ...



We can now import our supportedLocales and use them in a new <LocaleSwitcher> component, which wraps a humble <select>.

<script>

import { supportedLocales } from '../../i18n'



export default {

  methods: {

    // Called when the user selects a new locale

    // from the dropdown

    onLocaleChange(event_) {

      const newLocale = event_.target.value



      // If the selected locale is the same as the

      // active one, do nothing

      if (newLocale === this.$i18n.locale) {

        return

      }



      // Navigate to the localized root route for

      // the chosen locale

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

    },

  },

  computed: {

    // Transfrom our supportedLocales object to 

    // an array of [{ code: 'en-US', name: 'English' }, ...]

    locales() {

      return Object.keys(supportedLocales).map((code) => ({

        code,

        name: supportedLocales[code].name,

      }))

    },

  },

}

</script>



<template>

  <select

    :value="$i18n.locale"

    @change="onLocaleChange($event)"

  >

    <option 

      v-for="locale in locales"

      :key="locale.code"

      :value="locale.code"

    >

      {{ locale.name }}

    </option>

  </select>

</template>



🤿 Go Deeper » We’re using $router.push() in our <LocaleSwitcher> to navigate to the chosen locale’s root route. Learn more about Vue Router’s programmatic navigation in the official guide.

Now we can drop our new component into our app’s navigation bar for our users.

<script setup>

import LocalizedLink from './l10n/LocalizedLink.vue'

import LocaleSwitcher from './l10n/LocaleSwitcher.vue'

</script>



<template>

  <div>

    <nav>

      <img :alt="$t('logo')" src="@/assets/logo.svg"/>

      <span class="font-bold text-purple-300">{{ $t('appTitle') }}</span>

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

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

    </nav>



    <LocaleSwitcher />

  </div>

</template>



Our language switcher component in action

Our language switcher component in action

Binding directly to i18n.locale

If you’re not using localized routes, you can bind directly to $i18n.locale as follows.

<script>

import { supportedLocales } from '../../i18n'



export default {

  computed: {

    locales() {

      // ...

    },

  },

}

</script>



<template>

  <!-- Using Vue's v-model for two-way binding -->

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

    <option

      v-for="locale in locales"

      :key="locale.code"

      :value="locale.code"

    >

      {{ locale.name }}

    </option>

  </select>

</template>



🤿 Go Deeper » You can learn more about locale changing in Vue I18n’s documentation.

How do I load my translation files asynchronously?

As our apps grow, and we add more supported locales, we risk bloating our main bundle with all our translation messages. Realistically we will only need messages for the current visitor’s chosen locale. We can make our main bundle leaner by downloading only the active locale’s messages when needed.

Let’s add this async translation loading to our demo app. We’ll start by breaking our messages.js file up into per-locale JSON files.

{

  "appTitle": "Mushahed",

  "home": "Home",

  "about": "About",

  // ...

}



{

  "appTitle": "مشاهد",

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

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

  // ...

}



Next we’ll add a loading function to our i18n library.

import { nextTick } from 'vue'



// ...



let _i18n



// ...



async function loadMessagesFor(locale) {

  const messages = await import(

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

  )



  _i18n.global.setLocaleMessage(locale, messages.default)



  return nextTick()

}



export default {

  get vueI18n() {

    return _i18n

  },

  setup,

  setLocale,

  loadMessagesFor,

}



loadMessagesFor() uses Webpack’s async code splitting and dynamic imports to asynchronously load the translation file for the given locale. Once the translation file has loaded, it feeds the file’s messages to Vue I18n, associating them with the given locale. Finally, to ensure that Vue has updated the DOM before we resolve, we return the Promise from nextTick().

Now we can update the beforeEach() navigation guard in our router to load the locale’s messages before rendering a route’s associated component.

import { createRouter, createWebHistory } from 'vue-router'

import i18n, { defaultLocale } from '../i18n'



// ...



const router = createRouter({

  // ...

})



// We make the callback function async...

router.beforeEach(async (to, from) => {

  const newLocale = to.params.locale

  const prevLocale = from.params.locale



  if (newLocale === prevLocale) {

    return

  }



  // ...so we can wait for the messages to load

  // before we continue

  await i18n.loadMessagesFor(newLocale)



  i18n.setLocale(newLocale)

})



export default router



If we reload our app now we shouldn’t see any major changes. However, if we open the network tab in our browser’s developer tools, we should see that a message JSON file loads in when we switch locales.

Using Webpack's code splitting to asynchronously load a locale's translations

Using Webpack's code splitting to asynchronously load a locale's translations

🔗 Resource » Read more about async/lazy loading in Vue I18n’s Lazy loading guide.

How do I work with locale fallback?

You may have noticed some console warnings after implementing asynchronous translation loading above. The warnings occur when you load an en-US route for the first time.

Vue I18n attempting to fall back on a more general locale when it can't find a translation message

Vue I18n attempting to fall back on a more general locale when it can't find a translation message

What’s happening is that Vue I18n can’t find any en-US message when the app first loads. The en-US messages load in a HTTP request separate from the main bundle, so they may not be available when the app first loads. We’ll address this in a minute.

Notice, however, that Vue I18n tries to find the logo message in a general en locale when it can’t find it in the region-specific en-US locale. This is the library’s default fallback behaviour. It can come in quite handy when one of our locales is missing translations.

🤿 Go Deeper » Check out all of the options Vue I18n gives you for fallback in the Fallbacking guide.

A fallbackLocale config option is available to us as a bottom catch-all: Any locales we list under fallbackLocale will be used to display a message if one can’t otherwise be found. Let’s use this option to ensure that we fall back on English in our app.

// ...

import { createI18n } from 'vue-i18n'

import { numberFormats } from './numbers'

import { arabicPluralRules } from './plurals'

import { datetimeFormats } from './datetimes'

import defaultMessages from '../translations/en-US.json'



export const defaultLocale = 'en-US'



let _i18n



function setup(options = { locale: defaultLocale }) {

  _i18n = createI18n({

    locale: options.locale,

    fallbackLocale: defaultLocale,

    messages: { [defaultLocale]: defaultMessages },

    numberFormats,

    datetimeFormats,

    pluralizationRules: {

      'ar-EG': arabicPluralRules,

    },

  })



  setLocale(options.locale)



  return _i18n

}



// ...



We import our en-US translation messages and pass them into Vue I18n’s messages option. This will include our English messages in the main bundle, ensuring that our app won’t have to wait for them to load asynchronously. Setting en-US as the fallbackLocale ensures that the equivalent English message will be shown instead of a missing message in another locale.

The English message for "home" is used instead of the missing Arabic message

The English message for "home" is used instead of the missing Arabic message

 

Handy console warnings reveal Vue I18n's fallback chain

Handy console warnings reveal Vue I18n's fallback chain

With that in place, our little demo app is internationalized.

Our internationalized demo app

Our internationalized demo app

🔗 Resource You can get all of the code of the internationalized Options API demo app we built above from GitHub. The demo code includes some features we didn’t have space for in this article, like listening for locale changes to reload data and right-to-left language support.

How do I localize my Vue app with the Composition API?

Everything we’ve covered in this article so far pertains to Vue’s object-oriented Options API. If your app is using the Composition API in Vue 3, we got you covered in this section.

Heads up » Vue I18n is designed to work with either the Options API or Composition API, but not both. The Vue I18n Options API is the default and is called the Legacy API. Read the Migration to Composition API from Legacy API guide for information about limitations and caveats.

Before we refactor our I18n code, let’s briefly look at how we would refactor our Vue components (sans I18n) from the Options API to the Composition API.

🗒️ Note » The following sections build on what we’ve already covered in this article. If you’re new to Vue I18n it’s recommended that you read the rest of the article before continuing.

Only three files in our demo need to be refactored for Composition: AstroCard.vue, Astronauts.vue, and Coords.vue.

<-- Use script setup syntactic sugar for single-file components -->

<script setup>



// Import functions from Vue

import { computed } from 'vue'



// Use macro to define `props` 

const props = defineProps({

  name: String,

  photoUrl: String,

  nationality: String,

  craft: String,

})



// Create computed property using computed()

// and refactor prop reference to use `props.X`

const fullPhotoUrl = computed(() => `/img/astros/${props.photoUrl}`)

</script>



<template>

  <!-- Nothing changes in the template -->

</template>



<script setup>

import { ref } from 'vue'

import AstroCard from './AstroCard.vue'



// Use ref to define reactive data

const loading = ref(true)

const astros = ref([])



// Logic that would run in created() is

// written directly at the top level

fetch('/data/astronauts.json')

  .then((res) => res.json())

  .then((data) => {

    // Remember to use .value when getting/setting

    // values defined with ref()

    astros.value = data

    loading.value = false

  })

</script>



<template>

  <!-- Nothing changes in the template -->

</template>



🔗 Resource » We’ll stop here to keep things brief and get to the i18n. You can view the diff for the Composition API refactor (before i18n) on our GitHub repo.

Refactoring i18n to use the Composition API

There’s not too much to change if we want to use Vue I18n’s Composition API. Here’s what we’ll cover:

  • Setting legacy: false when creating the VueI18n instance.
  • Refactoring vueI18n.global.locale to vueI18n.global.locale.value.
  • Refactoring tc() calls to t() for plurals.
  • Refactoring all this.X calls to their functional equivalents in our component <script>s.

Let’s get to it.

Turning off legacy mode

By default Vue I18n is in “legacy” mode, where createI18n() returns a VueI8n object instance. We want the factory function to create a Composer instance, which provides functions like t() and n() to our composition component.

To accomplish this, we just need to pass one option to createI18n(), setting legacy: false.

// ...

import { createI18n } from 'vue-i18n'



export const defaultLocale = 'en-US'



// ...



let _i18n



function setup(options = { locale: defaultLocale }) {

  _i18n = createI18n({

    legacy: false,

    // Nothing else changes in our options

  })



  setLocale(options.locale)



  return _i18n

}



// ... 



Using reactive properties

As soon as we start using the Composition with legacy: false we need to refactor our calls to Vue I18n’s locale, since locale now acts like a reactive ref.

// ...



function setLocale(newLocale) {

  // Just like any Vue reactive ref, we have

  // to get/set it with .value

  _i18n.global.locale.value = newLocale

  setDocumentAttributesFor(newLocale)

}



// ...



That’s all we we have to change in our i18n library. The remaining updates will be in our components.

🔗 Resource » Check out the Composition API guide for additional info.

Using t() instead of tc() for plural messages

Vue I18n’s Composer instance doesn’t have a tc() function for outputting plural messages; switching to the Composition API will cause Vue I18n to throw an error whenever we attempt to use tc(). Instead, we can just the regular t() function as a drop-in replacement.

<script setup>

  // ...

</script>



<template>

  <!-- ... -->



  <div>

    <div>

      <h2>

      <!-- Use $t() instead of $tc(): works exactly the same -->

        🧑‍🚀 {{ $t('peopleInSpace', astros.length) }}

      </h2>



      <p>

        {{ $t('updatedAt', { date: $d(updated, 'short') }) }}

      </p>

    </div>



    <!-- ... -->

    </div>

  </div>

</template>



🗒️ Note » $t(), $d(), and $n() work in component <template>s in both Legacy and Composition modes. This is because, by default, Vue I18n injects them globally in both modes. This is not the case with component <scripts>, where $t(), $d() etc. are not available in Composition mode. We’ll deal with that next.

Using localization functions in component scripts

In our Coords component we’re using this.$t(), this.$d() and this.$n() to retrieve translation messages and localized dates and numbers, respectively.

<script>

export default {

  data() {

    return {

      loading: true,

      coords: null,

      datetime: '',

    }

  },



  created() {

    // We fetch coordinate data from the network here...

  },



  computed: {

    issPosition() {

      const { latitude, longitude } = this.coords

     

      return this.$t('issPosition', {

        latitude: this.$n(latitude, 'coords'),

        longitude: this.$n(longitude, 'coords'),

        datetime: this.$d(this.datetime, 'full'),

      })

    },

  },

}

</script>



<template>

  <!-- Display coordinate data -->

</template>



When we switch the Composition API, we no longer have this pointing to the component instance, so we can’t use this.$t() and its ilk anymore. Vue I18n provides a useI18n() function that returns the Composer instance, which includes t() and company. Let’s see it in action.

<script setup>

import { ref, computed } from 'vue'

import { useI18n } from 'vue-i18n'



// Destructure functions from returned Composer instance

const { t, n, d } = useI18n()



const loading = ref(true)

const coords = ref(null)

const datetime = ref('')



// We fetch coordinate data from the network here...



const issPosition = computed(() => {

  const { latitude, longitude } = coords.value



  // Use functions without `this`

  return t('issPosition', {

    latitude: n(latitude, 'coords'),

    longitude: n(longitude, 'coords'),

    datetime: d(datetime.value, 'full'),

  })

})

</script>



<template>

  <!-- ... -->

</template>



Heads up » Don’t use the $ prefix with the functional variants in component <script>s.

Using the reactive locale property

Just like this.$t() needed to be refactored, so to does this.$i18n.locale. We can get and set the active locale by destructuring it from the Composer instance in our components. Let’s refactor our LocaleSwitcher and LocalizedLink components to use the reactive locale property.

<script setup>

import { computed } from 'vue'

import { useI18n } from 'vue-i18n'

import { useRouter } from 'vue-router'

import { supportedLocales } from '../../i18n'



const router = useRouter()

const { locale } = useI18n()



function onLocaleChange(event_) {

  const newLocale = event_.target.value



  // Just like other reactive refs, we need to 

  // use locale.value to get/set the active locale

  if (newLocale === locale.value) {

    return

  }



  router.push(`/${newLocale}`)

}



const locales = computed(() =>

  Object.keys(supportedLocales).map((code) => ({

    code,

    name: supportedLocales[code].name,

  }))

)

</script>



<template>

  <!-- Notice that $i18n.locale is still available in

       our component templates -->

  <select

    :value="$i18n.locale"

    @change="onLocaleChange($event)"

  >

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

      {{ locale.name }}

    </option>

  </select>

</template>



<script setup>

import { computed } from 'vue'

import { RouterLink } from 'vue-router'

import { useI18n } from 'vue-i18n'



const props = defineProps(['to'])



const { locale } = useI18n()



const localizedUrl = computed(() =>

  props.to === '/' ? `/${locale.value}` : `/${locale.value}/${props.to}`

)

</script>



<template>

  <router-link :to="localizedUrl">

    <slot></slot>

  </router-link>

</template>



🔗 Resource » The Composer instance exposes other properties: Check out the API docs for a comprehensive listing.

And with that, our refactor is complete.

Our internationalized demo app

Our demo works exactly as it did in Legacy mode

🔗 Resource » You can get the complete code for the Composition i18n demo from GitHub.

🔗 Resource » If you're interested in general JavaScript i18n, including other UI and i18n libraries, you might enjoy our Ultimate Guide to JavaScript Localization.

That about does it for our Vue 3 i18n demo. We hope you enjoyed it and learned a few things along the way. If you’re looking to take your i18n game to the next level, check out Phrase. Phrase supports Vue I18n out of the box with its In-Context Editor, allowing your translators to update messages directly in your app. The fully-featured Phrase web console, with machine learning and smart suggestions, is a joy for translators to use. Once translations are ready, they can sync back to your project automatically — Phrase comes with a CLI and syncs with Bitbucket, GitHub, and GitLab. You set it and forget it, leaving you to focus on the code you love. Check out all the features Phrase has to offer, and give it a spin with a 14-day free trial.