Software localization

Beginning JavaScript I18n with i18next and Moment.js

Building a JavaScript app and want to hit the ground running quickly? Here's how to use i18next and Moment.js for your custom JavaScript i18n library.
Software localization blog category featured image | Phrase

Let's assume we're building a new magazine web app for web developers, and we're past initial prototyping phases. We're ready to work on our alpha release. One of our core market differentiators is that we will provide quality content in Arabic, English, and French. So, we'll need a good foundation for our browser and JavaScript i18n.

Our CEO is anxious to go live and start gathering real user feedback quickly because she believes in all this lean startup stuff. Our project manager has made sure mockups for this agile sprint are ready and that our designer has exported everything for the dev team.

The team is working with React for a view layer. However, our development lead wants us to write a framework-agnostic i18n / l10n library in ECMAScript 6. Our library should work with Angular, React, Vue, you name it.

🔗 Resource » Find everything you need to make your JS apps accessible to users around the world in our Ultimate Guide to JavaScript Localization.

I18next

Ok, we've got our work cut out for us. To hit the ground running during this sprint, we'll want a good JavaScript i18n third-party library as a layer underneath our own i18n API. i18next looks like a good choice: it's feature-full and seems quite reliable.

Moment.js

I18next doesn't cover date formatting. Instead, we'll wire up i18next with the popular Moment.js library. Moment has good i18n and l10n date and time support built right into it.

Feature Laundry List

This isn't our first rodeo, and we know any useable i18n library should provide the following:

  • Supported locales and validation
  • Locale output (for say, a language switcher)
  • Fallback to default locale (when translations are missing)
  • Loading translation files
  • Getting and setting the active locale
  • Determining the active locale from the user request
  • Getting the active locale's directionality (right-to-left or left-to-right)
  • Translation retrieval by key e.g. t('author_name')
    • Simple plural handling e.g. t('article_count', {count: articles.length})
    • Multi-variant plural handling (for Arabic: few, many, etc.)
  • Date and time formatting and output

Luckily for us, i18next and Moment handle many of those things out of the box. Our basic architecture will look like this:

Architecture Diagram | Phrase

Locale Support

Alright, scaffolding time. We can start on a little i18n library to serve as a foundation for our i18n and l10n work. Even though we will use i18next under the hood, we don't want to tightly couple our i18n API to a third-party library. So we'll build a little adapter around i18next to free us up for potential library swapping in the future.

First off, we were told that our magazine will serve Arabic, English, and French readers. Let's get that configured, shall we?

/src/config/i18n.js

/** @type {Object.<string, string>} */

export const SUPPORTED_LOCALES = {

    'ar': 'عربي',

    'en': 'English',

    'fr': 'Français',

}

This can get us started on the beginnings for our i18n service library.

/src/services/i18n/lang.js

import {SUPPORTED_LOCALES} from '../../../config'

const lang = {

    /**@type {Array.<string>} */

    get supported() {

        return Object.keys(SUPPORTED_LOCALES)

    },

    /**

     * @param {string} localeCode

     * @return {bool}

     */

    isSupported(localeCode) {

        return !!SUPPORTED_LOCALES[localeCode]

    },

    /**

     * Name of the locale in its own language

     *

     * @param {string} localeCode

     * @return {string}

    */

    nameFor(localeCode) {

        return SUPPORTED_LOCALES[localeCode]

    }

}

export {lang}

A simple singleton with a supported property and a couple of helpful methods for checking support and locale output can get us started.

A Language Switcher Component

We spike out a language switcher to make sure our feet are on the ground and we have some feature testing in place.

/src/components/LanguageSwitcher.js

import React from 'react'

import {lang} from '../services/i18n/lang'

const LocaleSwitcher = function() {

    return (

        <ul className="language-switcher">

            {lang.supported.map(code => {

                return (

                    <li key={code} className="language-switcher__locale">

                        <a href={`/?locale=${code}`}>{lang.nameFor(code)}</a>

                    </li>

                )

            })}

        </ul>

    )

}

export default LocaleSwitcher

The above will output a ul > li > a structure with a link for each supported locale looking like <a href="/?locale=ar">عربي</a>.

We'll assume that we'll determine the locale via a query string parameter called locale. We'll visit locale determination again soon, but for now we've got basic locale support in place.

Note: If you're not familiar with the above code, don't worry. It's a React component that we're using to demonstrate output. However, our focus remains the foundation for our JavaScript i18n.

The Fallback Locale

We'll want a default locale to use when we can't find translations for the requested locale.

/src/config/i18n.js (excerpt)

/** @type {string} */

export const FALLBACK_LOCALE = 'en'

/src/services/lang.js (excerpt)

    /** @return {string} */

    get fallback() {

        return FALLBACK_LOCALE

    },

Initializing and Loading Translation Files

Translation URL Schema

Let's assume that by default our translation files will be at /translations/en.json. To foster convention over configuration, let's provide this as the default configuration for our translation files' URL schema.

/src/config/i18n.js (excerpt)

/** @type {string} */

export const DEFAULT_TRANSLATION_URL_SCHEMA = '/translations/{locale}.json'

The {locale} placeholder will be replaced with the active locale's code, e.g. 'fr'.

Ok, it's about time to bring in i18next for some a bit of heavy lifting. Let's look at how we want our API to work first.

/src/App.js (excerpt)

lang.init()

    .then(() => this.setState({translationsDidLoad: true}))

    .catch(() => this.setState({loadingTranslationsDidError: true}))

Note: setState() updates our view model.

Or with optional overrides:

/src/App.js (excerpt)

lang.init({

    locale: 'en',

    loadTranslationsFrom: '/assets/lang/{locale}.json',

    debug: true,

})

.then(() => this.setState({translationsDidLoad: true}))

.catch(() => this.setState({loadingTranslationsDidError: true}))

We can override automatic locale determination by forcing a locale via the locale option. Similarly, we can override the default translation file url schema. During development, we can also set our debug flag to true to get helpful debug messages in the console as we develop.

Ok, this API looks like a good start. Now, let's get it working. Our first step is initializing i18next.

/src/services/i18n/lang.js (excerpt)

import i18next from 'i18next'

import {SUPPORTED_LOCALES} from '../../config'

/**

 * @param {Object.<string, mixed>} opt options

 * @param {function} onSuccess

 * @param {function(string)} onError

*/

const initI18next = function (opt = {}, onSuccess, onError) {

    i18next.init(opt, (err) => {

        if (err && onError) {

            onError(err)

        }

        onSuccess()

    })

}

const lang = {

// ...

i18next has its own init function that accepts an options object and a callback. If the library throws during initialization, it will pass a truthy value as the err argument to our callback.

We wrap a convenience function around i18next's initializer, passing through an options object and providing a common onSuccess, onError callback parameter pattern. To hide this implementation, we encapsulate the initI18n function outside of the lang object.

Let's use this function to write our own library's initializer method.

// ...

import {

    SUPPORTED_LOCALES,

    FALLBACK_LOCALE,

} from '../../config/i18n'

import http from '../http'

// ...

const lang = {

    /**

     * Load library and translation files

     *

     * @param {?Object<string, mixed>} opt

     * @param {?string} opt.locale defaults to fallback

     * @param {?string} opt.loadTranslationsFrom defaults to configured value.

     *                                           accepts `{locale}` placeholder e.g.

     *                                           '/assets/translations/{locale}.json'

     * @param {?bool} debug defaults to false

     * @return Promise

     */

    init(opt) {

        const locale = opt.locale || this.fallback

        if (!this.isSupported(locale)) {

            return Promise.reject(`${locale} locale is not supported.`)

        }

        // we'll pass these to I18next

        const commonOpt = {

            lng: locale,

            debug: opt.debug || false,

        }

        const fileUrlSchema = opt.loadTranslationsFrom ||

                        DEFAULT_TRANSLATION_URL_SCHEMA

        // we create the locale URL from the given locale option,

        // then we make an AJAX call to retrieve the translation

        // file. once it's loaded, we pass its JSON on to i18next

        // and initialize it.

        return new Promise((resolve, reject) => {

            http.get(fileUrlSchema.replace(/{locale}/, locale))

                .then(response => {

                    let resources = {}

                    resources[locale] = response.data

                    initI18next(

                        {resources,...commonOpt},

                        () => resolve(),

                        err => reject(err)

                    )

                })

                .catch(err => reject(err))

        })

    },

    // ...

We first grab the locale parameter and make sure that we support it. Then we setup some common i18next options, including the helpful debug option, which I18next helpfully provides for us.

i18next accepts a resources options with JSON key / value translations: so we simply load our current locale's translation file and feed its JSON to i18next. Any errors are passed onto the caller to handle, whether they be from loading the file or initializing i18next.

Note: i18next provides its own translation file loading capabilities. However, our team already has an HTTP library in place, so we'll utilize that to keep things DRY and to lower our JavaScript asset sizes.

Now we can add our translation files.

/public/translations/en.json

{

    "translation": {

        "app_name": "μ",

        "lead": "For the love of web dev"

    }

}

We can have a similar file for every locale we support. By default, i18next will expect our keys to be within namespaces. The default namespace is translation. We'll support this namespace in our own library, as it self-documents and allows for greater flexibility in the future.

Getting and Setting the Active Locale

We need the notion of an active or current locale in our library. Let's write a little method to handle that.

/src/services/i18n/lang.js (excerpt)

   /**

     * Get or set the active locale

     *

     * @param {?string} newLocaleCode

     * @return {string}

     */

    active(newLocaleCode = i18next.language) {

        if (newLocaleCode && this.isSupported(newLocaleCode)) {

            i18next.language = newLocaleCode

        }

        return i18next.language || this.fallback

    },

Before we activate a given locale, we first make sure that we support it. We defer the caching of the active locale to i18next, and make sure we handle fallback.

Note: We're not yet handling setting the active locale after initialization. This case is uncommon enough that it can be deferred to a future sprint (and article).

Now, let's get this baby working for us by refactoring our initializer.

/src/services/i18n/lang.js (excerpt)

   init(opt) {

        // handle fallback

        const locale = this.active(opt.locale)

        // ...

    },

Instead of having our fallback logic in our initializer, we now run it whenever the user sets the active(locale). This cleans up our initializer as well.

Determining the Active Locale from the User Request

Remember how we envisioned locales in our URLs? When we wrote our language switcher, they looked something like /foo/bar?locale=fr. Let's wire that up.

/src/config/i18n.js (excerpt)

/** @type {string} */

export const LOCALE_QUERY_PARAM = 'locale'

A simple getLocaleFromUserRequest method can be added to our library.

/src/services/i18n/lang.js (excerpt)

import {

    // ...

    LOCALE_QUERY_PARAM,

} from '../../config/i18n'

//...

const lang = {

   // ...

   /** @return {string} */

    getLocaleFromUserRequest() {

        return (new URLSearchParams(window.location.search))

                 .get(LOCALE_QUERY_PARAM)

    },

    // ...

We use the URLSearchParams object, built into modern browsers, to retrieve the locale query string parameter.

And another quick refactor to round us out:

/src/services/i18n/lang.js (excerpt)

const lang = {

    init(opt) {

        const locale = this.active(opt.locale || this.getLocaleFromUserRequest())

        // ...

    },

// ...

If the developer doesn't provide a locale explicitly on initialization, a simple suffixing of our app routes will now set the active locale. For example, /categories/front-end?locale=fr sets our active locale to French.

At this point we get a visit from our CEO, who's wondering what we've been doing the last couple of days. We tell her we've just built a lot of our scaffolding that will allow multiple languages and locales to be supported on our site. She asks us to show her something and we present a blank page with a language switcher. "We're doomed," she sighs. 🙄. Let's get to our UI.

Translation Retrieval by Key

To show translations we need a translation function that accepts a key in a language file and returns its value. Let's add that function to our lang library and export it.

/src/services/i18n/lang.js (excerpt)

// ...

const lang = {

    // ...

    /**

     * Retrieve a translation for the active locale

     *

     * @param {string} key

     * @param {?Object<string, mixed>} opt

     * @param {?number} opt.count

     * @return {string}

     */

    t(key, opt) {

        return i18next.t(key, opt)

    },

    // ...

}

export default lang.t

export {lang}

i18next is doing the work for us here. We simply pass the key and any options to the underlying i18next.t() function. The count option is for plurals. We'll get to those a bit later.

Now we can use our t() function to output translated copy in our view components.

/src/App.js (excerpt)

// ...

import t from './services/i18n/lang'

class App extends Component {

    // ...

    render() {

        return (

            <div>

                <h1>{t('app_name')}</h1>

                {/* ... */}

            </div>

       );

    }

}

The above will output the app_name translation in the active locale, or fallback. It's really that simple.

Directionality

Our magazine will have Arabic content, and that means we need to handle right-to-left layouts and text directionality. Again, i18next has got our backs.

/src/services/i18n/lang.js (excerpt)

   /**

     * Retrieve the active locale's directionality

     *

     * @return {string} 'ltr' | 'rtl'

     */

    get dir() {

        return i18next.dir()

    },

We can update the document's layout and flow direction early in our app's lifecycle: our root component is a good place to put this logic for now.

/src/App.js (excerpt)

// ...

import t, {lang} from './services/i18n/lang'

class App extends Component {

    // ...

    componentDidMount() {

        lang.init({debug: true})

            .then(() => {

                // set the page's directionality

                window.document.dir = lang.dir

                this.setState({translationsDidLoad: true})

            })

            .catch(err => this.setState({loadingTranslationsDidError: true}))

    }

    // ...

window.document.dir = lang.dir is all it takes to flip the whole page around. Let's move on.

Simple Plurals

i18next.t() handles plurals without much intervention. In fact, by deferring translation value resolution to i18next, our t() function already has plural support built-in.

/src/services/i18n/lang.js (excerpt)

    t(key, opt) {

        return i18next.t(key, opt)

    },

i18next accepts a convention for simple, two-variant plurals in our translation file keys.

/public/translations/en.js (excerpt)

{

    "translation": {

        // ...

        "article_count": "{{count}} article",

        "article_count_plural": "{{count}} articles"

    }

}

Notice the _plural suffix above. Key foo and foo_plural are respectively considered as the singular and plural of the same phrase by i18next.

Note: i18next uses a {{placeholder}} convention for dynamic values in our translations.

We can now use our good ol' t() function in our views to get correct pluralization.

t('article_count', {count: 0}) // -> "0 articles"

t('article_count', {count: 1}) // -> "1 article"

t('article_count', {count: 2}) // -> "2 articles"

Done and dusted.

Multi-variant Plurals

Arabic is a tricky language when it comes to localizing plurals. The article_count phrase above will need special treatment in Arabic.

  • 0 articles → 0 مقالات
  • 1 article → مقال 1
  • 2 articles → مقالان
  • 3-10 articles → 7 مقالات
  • 11+ articles → 200 مقال

Yikes 😟! No worries. Once more, i18next has us covered.

/public/translations/ar.json (excerpt)

{

    "translation": {

        // ...

        "article_count_0": "{{count}} مقالات", // zero

        "article_count_1": "مقال {{count}}",  // singular

        "article_count_2": "مقالان",           // two

        "article_count_3": "{{count}} مقالات", // three to ten

        "article_count_4": "{{count}} مقال",  // eleven to ninety-nine

        "article_count_5": "{{count}} مقال"   // one hundred+

    }

}

i18next has multiple plural support out of the box, and uses a simple index convention to retrieve the right translation. Again, we're deferring our translation value resolution to i18next.t(), so we get this pluralization support for free. We can now use the same translation key in our views, and handle locale nuances in each locale's translation files.

t('article_count', {count: 0}) // -> "0 مقالات"

t('article_count', {count: 1}) // -> "مقال 1"

t('article_count', {count: 2}) // -> "مقالان"

// ...etc

Localicious! Alright, let's get to date and time.

Dates

At time of writing, i18next doesn't handle date and time formatting out of the box. It does, however, allow us to provide our own formatting via an interpolator. We can create a simple date format adapter and wrap it around the Moment library. i18next can then accept our formatter as an interpolator. Once we've got all that connected, we can use date formatting in our translation files.

/public/translations/ar.json (exerpt)

{

    "translation": {

        // ...

        "published_on": "نشر {{date, DD/MM/YYYY}}"

    }

}

And in our views:

t('published_on', {date: new Date(article.published_on)})

Let's get that working.

A Date Format Adapter

i18next accepts a formatting function with a format(value, format, locale) signature that returns a string. We'll be using Moment.js under the hood, so we'll need to handle its own locale loading. To keep things organized and easy to reason about, it's probably best to build a separate module for date formatting.

/src/services/i18n/date.js

import moment from 'moment'

const date = {

    /**

     * Load library, setting its initial locale

     *

     * @param {string} locale

     * @return Promise

     */

    init(locale) {

        // moment defaults to English and will throw an

        // error if we attempt to explicitly import 'en'

        if (locale === 'en') {

            return Promise.resolve()

        }

        // we load moment's l10n dynamically based on the

        // given locale, so that we don't have to statically

        // load all supported locales at the top of the

        // file

        return new Promise((resolve, reject) => {

            import(`moment/locale/${locale}`)

                .then(() => {

                    moment.locale(locale)

                    resolve()

                })

                .catch(err => reject(err))

        })

    },

    /**

     * @param {Date} date

     * @param {string} format

     * @return {string}

     */

    format(date, format) {

        return moment(date).format(format)

    }

}

export default date

Moment has its own l10n with support for quite a few locales, which we have to import explicitly. Thankfully, Arabic, English, and French are among them. However, we don't want to statically load all of our supported locales—minus English, which is built into Moment's core—like this:

import 'moment/locale/ar'

import 'moment/locale/fr'

This could be ok for a couple of locales, but wouldn't scale well at all. It also duplicates our supported locale list, which can be a source of issues in the future. Instead, we dynamically import the given locale on initialization.

We also provide a format() method, wrapping Moment's formatter and adapting it to what i18next expects.

Let's wire up our date formatter with our core i18n library.

/src/services/i18n/lang.js (excerpt)

// ...

import date from './date'

// ...

/**

 * @param {Object.<string>} opt options

 * @param {function} onSuccess

 * @param {function(string)} onError

*/

const initI18next = function (opt, onSuccess, onError) {

    // we initialize our date formatter with the given

    // locale. then we initialize i18next and pass it

    // our formatter

    date.init(opt.lng)

        .then(() => {

            const i18nextOpt = {

                ...opt,

                interpolation: {

                    format(value, format, locale) {

                        if (value instanceof Date) {

                            return date.format(value, format)

                        }

                        return value

                    }

                }

            }

            i18next.init(i18nextOpt, (err) => {

                if (err && onError) {

                    onError(err)

                }

                onSuccess()

            })

        })

        .catch(err => onError && onError(err))

}

// ...

We make sure our date formatter has initialized correctly before we attempt to initialize i18next, and we add an interpolation.format function to the options we give to i18next. interpolation.format conditionally checks if the value its given is a Date, and delegates to our date formatter if it is.

We can now add translations that format dates in our translation files.

/public/translations/ar.json (exerpt)

{

    "translation": {

        // ...

        "published_on": "نشر {{date, DD/MM/YYYY}}"

    }

}

And in our views:

t('published_on', {date: new Date(article.published_on)})

i18next will now pass the date formatting to our date.format method, which in turn uses Moment.js under the hood. This means we can use all the formatting options available in Moment. The ll format, for example, outputs a localized version of "Jan 21, 2018".

/public/translations/ar.json (exerpt)

{

    "translation": {

        // ...

        "published_on": "نشر {{date, ll}}"

    }

}

And in our views:

t('published_on', {date: new Date(article.published_on)}) // -> نشر ٢٠١٨/٠١/٢١

Unescaped Output

Actually the above will output something more like:

Arabic Date with Encoded Slashes | Phrase

This is due to Moment outputting / in its hexadecimal entity form. i18next is then escaping this output to guard against cross-site scripting (XSS) attacks. To undo that escaping, we can use a special operator that i18next provides.

/public/translations/ar.json (exerpt)

{

    "translation": {

        // ...

        "published_on": "نشر {{- date, ll}}"

    }

}

The - above tells i18next not to escape our formatter's returned value before outputting it via t(). Now we get something that looks like نشر ٢٠١٨/٠١/٢١

Note: Be careful about unescaping values, as you might expose yourself to XSS attacks if the values you're unescaping are coming from user input. Best to make sure that unescaping is well understood among your translation team.

A Note on Asset Sizes

To get going quickly, we've pulled in both i18next and Moment.js. This sped up our development significantly, but also came at the cost of potential asset bloat. I8next is 39KB (10.1KB gzipped). Moment.js weighs in at 316.2KB (67.4KB gzipped). While an added ~80KB (gzipped) may not seem unreasonable for our app bundle, this kind of thing will add up over time. In the future, we may well want to refactor our lang and date libraries to swap in smaller alternatives to i18next and Moment, or roll our own. Since we built our libraries as adapters, their interfaces don't have to change when we do refactor, so the rest of the team can keep using our i18n solution with minimal changes to the rest of the code base.

Conclusion

Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately, Phrase can make your life as a developer easier! Feel free to learn more about Phrase, referring to the Phrase Localization Suite.

As we've developed our i18n / l10n library, the rest of the development and translation team have been integrating it into their views. With a few adjustments at the end of the sprint, our magazine app is localizable and we're ready to serve our multi-lingual content.

Our development lead is pleased that we designed interfaces decoupled from any third-party library, which reduces our lock-in and technical debt. And our CEO, previously anxious about whether we'd be able to deliver our alpha release on time, is now anxious about how expensive our team's espresso habit is becoming 🤦🏽‍♂️. Oh well.