How to (Best) Set the Initial Locale in Vue I18n

Internationalization | Vue I18n

The Problem

If you’re using Vue I18n (or any internationalization plugin, really), you’re going to need to define the initial locale. That is, the locale used to present your content when a user first arrives at your application.

If you’re reading this, you probably already know that we can define that locale using the locale option of the createI18n method:

createI18n({
  locale: ...,
});

The question is: What value should we use for locale? Well, let’s assume we simply want to use the preferred language of the current user. We can access that information using Javascript’s Navigator interface. Specifically, the navigator.language property:

createI18n({
  locale: navigator.language,
});

There we have it, the initial locale of our Vue I18n instance now matches the locale of our current user (where that locale is likely derived from the language settings of their browser).

Is this all we need to do? Well, this article probably wouldn’t exist if it was. For simple applications (usually with a limited degree of control over language selection), this may be sufficient. We’ll have an initial locale set to something sensible, and if the current user’s locale is not supported, we can still utilise fallbacking in an attempt to find an appropriate substitute.

However, more complex/realistic applications (usually those that offer language selection capabilities), will find this approach lacking. In these cases, we are likely to have a fixed set of supported locales, where we want to enforce that the locale in use is confined to this set.

At this point, the original question has evolved in to:

How can we take the user’s preferred locale, and best match it to one our supported locales?

The Naive Solution

You might be wondering why, in the question above, we used the term “best” match. Can’t we just return an exact match? Well, let’s start by looking at the “naive” solution to this problem. We’ll start by just searching for navigator.language in our list of “supported” locales:

const supportedLocales = ['en', 'fr', 'de'];

const userLocale = navigator.language;

const initialLocale = supportedLocales.includes(userLocale) ? userLocale : 'de';

We attempt to find userLocale within our list of supportedLocales. If there’s a match, we’ll use userLocale as our initial locale. Otherwise, we’ll fallback to the de locale.

Unfortunately, this solution is hinged on the premise that, if our user’s preferred language is English, French or German, navigator.language will consistently return exactly en, fr or de, respectively.

More often than not, this is not the case. The locale “identifier” returned by navigator.language is not guaranteed to be consistent. Different browsers and devices can return slightly different identifier strings, even when using the same “language”. In fact, I first encountered this issue at work after employing a similarly naive solution to the one listed above. A colleague identified a bug due to the fact, on his browser, navigator.language would return en-GB, whereas mine would return en. This caused his initial locale to be set to the fallback, even though en, realistically, should have been used.

If we dig a bit deeper in to these locale identifiers, we find that they can be composed of many “subtags”, usually separated by a ”-“. Whilst the identifier may sometimes consist of only a single language subtag (e.g., en or de), it oftentimes includes extra subtags, used to convey information such as the region or the particular script used. For example:

As we can see, these identifiers can start to get fairly verbose. Ultimately, this is why we used the term “best” match earlier. It’s unlikely that, from our limited set of locales, there will be an exact match with the result of the navigator.language. However, we still want to attempt to find a best match, regardless of how complex the given locale identifier may be.

Additionally, we want to allow our own set of supported locales to contain extra subtags. For example:

const supportedLocales = ['en-GB', 'en-US', 'de', 'zh-Hans'];

With these supported locales:

The Better Solution

Implementing the solution to this, from scratch, requires a lot of tedious string wrangling. To provide all the details in article-form would simply be too long-winded. Instead, I have already published a small, zero-dependency, TypeScript-compatible plugin to do this for you: match-supported-locale. The remainder of this post will simply explain how to install and use this plugin. If you would rather implement the solution yourself, or better understand the underlying solution, please see the source code on GitHub.

First, lets install it:

npm install match-supported-locale

We can now import a matchSupportedLocale function that, given a set of supported locales, will attempt to find a best match against a given target locale (e.g., navigator.language):

const fallbackLocale = 'de-DE';

const targetLocale = navigator.language;

const supportedLocales = ['en', 'zh', 'de'];

const locale =
  matchSupportedLocale(targetLocale, supportedLocales) ?? fallbackLocale;

createI18n({
  locale,
});

Here we’ve said that we support 3 locales, all of which consist of single language subtags. As long as the identifier string returned by navigator.language contains one the supported language subtags somewhere (regardless of how complex it may otherwise be), the return value of matchSupportedLocale is guaranteed to be one of our supported locales.

For example:

If the identifier string returned by navigator.language does not contain any of the supported language subtags (e.g., fr), then we’ll still need to provide an overall fallbackLocale, as matchSupportedLocale doesn’t have enough information to decide what to do in this case.

If your application only supports locales consisting of a single language subtag, then the code above is probably all you need. However, as we mentioned above, we want to support locales containing multiple subtags, such as different regions of the same language (en-US vs en-GB). To do this, we’ll need one extra bit of configuration:

const fallbackLocale = 'de-DE';

const targetLocale = navigator.language;

const supportedLocales = ['en-GB', 'en-US', 'fr-FR', 'de-DE', 'de-LI'];

const languageLocaleFallbacks = {
  en: 'en-GB',
  fr: 'fr-FR',
  de: 'de-DE',
};

const locale =
  matchSupportedLocale(
    targetLocale,
    supportedLocales,
    languageLocaleFallbacks,
  ) ?? fallbackLocale;

createI18n({
  locale,
});

This time around, we see that we support multiple regions of the same language. Additionally, matchSupportedLocale has been provided a new third argument of languageLocaleFallbacks.

The reason we need this extra configuration is because, for example, we no longer support just en as a locale. So, what if navigator.language actually does return en, or even en-AU? We certainly don’t want matchSupportedLocale to hit the fallbackLocale of de-DE, we still want it to use some version of English. But, how can it decide whether to use en-GB or en-US?

Well, it can’t decide. Hence, you need to provide that information through languageLocaleFallbacks. This object should state, for each language locale, what the fallback locale should be when the target locale matches a supported language, but none of the particular regions, scripts, etc.

With this configuration:

That’s about all there is to it. You should now be able to guarantee that the initial locale of your Vue I18n instance matches - as accurately as possible - one of your supported locales.

Of course, I’ve used Vue I18n as a means of providing a concrete example, but note that usage of match-supported-locale is agnostic of any particular plugin/framework. Additionally, you are free to match against any target locale, it does not necessarily need to be derived from navigator.language.