The Ultimate Guide: Node.js Internationalization (I18n)

If you are a serious Node.js Software Engineer creating web applications with Express, Koa, or any similar framework, you will need to be able to internationalize your app to let it support different locales. In this tutorial, we will see how to set up i18n in Node.js, and how to organize your translations so that your app can reach as many international users as possible.

Node.js is an asynchronous event-driven JavaScript runtime designed to help build scalable network applications. In essence, it allows JavaScript to run in the backend as a server-side code.

The Node ecosystem is vast and it relies on community projects. Although there are numerous tutorials online exploring Node.js and its libraries, the topic of Node internationalization is almost left behind.

So, when you have the need for scalable I18n solutions that are easy to use and implement, it pays to make some sensible software architecture demissions upfront.

This tutorial will try to fill that gap by showing ways of integrating i18n and adapting to different cultural rules and habits in your Node.js applications in a sensible manner.

For the purposes of this tutorial, I will be using the latest Node.js LTS runtime v8.94 and the code for this tutorial is hosted on GitHub. For convenience, we are going to use the –experimental-modules flag in order to use es6 imports in code. You can achieve the same result using babel with preset-es2015.

Let’s get started!

Setting up the Project

The Node.js runtime library is providing only the basic low-level primitives in order to write server applications. So in order to start with i18n, we have to start from scratch. That’s partly good because it allows us to have total control over the design decisions that may affect our project scope.

Initiate a new Node.js app

For the purposes of this tutorial, I’ve taken a lot of inspiration from this node API boilerplate where you start with a good yet opinionated base project for your Node.js applications.

Create the initial folder structure.

$ mkdir node-i18n-example && cd node-i18n-example
$ npm init --yes

Create a Locale Service

We need to have a service that will handle any i18n calls and will work as a mediator for any underlying i18n library we may use or may not use.

1. Create the service folder:

$ mkdir -p app/services && cd app/services

2. Add the file contents with your favorite editor:

$ touch localeService.mjs

File: app/services/localeService.mjs

/**
 * LocaleService
 */
export class LocaleService {
  /**
   *
   * @param i18nProvider The i18n provider
   */
  constructor(i18nProvider) {
    this.i18nProvider = i18nProvider;
  }

  /**
   *
   * @returns {string} The current locale code
   */
  getCurrentLocale() {
    return this.i18nProvider.getLocale();
  }

  /**
   *
   * @returns string[] The list of available locale codes
   */
  getLocales() {
    return this.i18nProvider.getLocales();
  }

  /**
   *
   * @param locale The locale to set. Must be from the list of available locales.
   */
  setLocale(locale) {
    if (this.getLocales().indexOf(locale) !== -1) {
      this.i18nProvider.setLocale(locale)
    }
  }

  /**
   *
   * @param string String to translate
   * @param args Extra parameters
   * @returns {string} Translated string
   */
  translate(string, args = undefined) {
    return this.i18nProvider.translate(string, args)
  }

  /**
   *
   * @param phrase Object to translate
   * @param count The plural number
   * @returns {string} Translated string
   */
  translatePlurals(phrase, count) {
    return this.i18nProvider.translateN(phrase, count)
  }
}

The LocaleService  is a simple class that accepts an i18nProvider  object that will help make working with locales a little bit easier. We might have a little bit more flexibility that way as we may decide to switch our provider without breaking much or our API calls.

Now what’s missing is the actual i18n provider. Let’s add one.

Adding an I18n provider

While there are a few reasonable choices when it comes to I18n, the most popular library at the moment is i18n-node.

The installation is pretty forward:

1. Install the package

$ npm install i18n --save

2. Create a i18n config object because before we use the library we need to configure it:

$ touch app/i18n.config.mjs

File: app/i18n.config.mjs

import i18n from 'i18n';
import path from 'path';

i18n.configure({
  locales: ['en', 'el'],
  defaultLocale: 'en',
  queryParameter: 'lang',
  directory: path.join('./', 'locales'),
  api: {
    '__': 'translate',  
    '__n': 'translateN' 
  },
});

export default i18n;

Here we added support for 2 languages with the default one being en. We also defined a locale directory that will be used by the library for autogenerating the initial translation files and strings. The API property is just a mapping from the __ call to translate call in our localeService . If you don’t want that arrangement you can change the call from this.i18nProvider.translate(string, args)  to this.i18nProvider.__(string, args) .

Tip: Look at the whole list of config options for that library here.

3. Test the Locale Service by instantiating an i18n object and a localeService:

$ touch index.mjs

File: index.mjs

import { LocaleService } from './app/services/localeService.mjs';
import i18n from './app/i18n.config.mjs';

const localeService = new LocaleService(i18n);

console.log(localeService.getLocales()); // ['en', 'el']
console.log(localeService.getCurrentLocale()); // 'en'
console.log(localeService.translate('Hello')); //  'Hello'
console.log(localeService.translatePlurals('You have %s message', 3)); // 'You have 3 messages'

Then in the command line run the following:

$ node --experimental-modules index.mjs

This will automatically generate a locales directory on the root folder containing the relevant translation strings for the current language:

$ tree locales 
locales
├── el.json
└── en.json

File: locales/en.json

{
   "Hello": "Hello",
   "You have %s message": {
      "one": "You have %s message",
      "other": "You have %s messages"
   }
}

Add the following line in index.mjs to test the generation of translatable strings for the other language:

localeService.setLocale('el');

File: locales/el.json

{
   "Hello": "Για Σας",
   "You have %s message": {
      "one": "Έχεις %s μύνημα",
      "other": "Έχεις %s μύνηματα"
   }
}

Run again the app and to verify that the translations are happening:

$ node --experimental-modules index.mjs
Για Σας
Έχεις 3 μύνηματα

Wire everything up with a DI container

Currently, in order to use our localeService class, we have to manually instantiate it and pass on the i18n config object. What’s even worse is that we need to keep only one reference to this object as we need to keep the current locale state in one place.

Would it be nice if we had a way to configure and retrieve on demand our localeService instance whenever in our application requests it? Would it be even nicer than any parameters also that needed to be provided at creation time be resolved also?

It turns out that there is a way to do that with the help of a Dependency Inversion Container. This is just an object that exposes a top-level API that allows us to register our valuable objects or services and request them in another time and place. This Dependency Inversion Container is one form of inversion of control (IoC) and helps with reusability, testability and better control.

For Node.js there is a nice library called awilix that offers those features. Let’s see how can we integrate this library for the sake of a better application structure.

1. Install awilix

$ npm install awilix --save

2. Create a file named container.mjs that will keep track of all the service registrations that we will need.

$ touch app/container.mjs

File: app/container.mjs

import awilix from 'awilix';
import i18n from './i18n.config';
import { LocaleService } from './services/localeService.mjs';

const container = awilix.createContainer();

container
  .register({
    localeService: awilix.asClass(LocaleService, { lifetime: awilix.Lifetime.SINGLETON })
  })
  .register({
    i18nProvider: awilix.asValue(i18n)
  });

export default container;

As you can see we have a greater flexibility on how we want our objects to be instantiated. For this example we want the LocaleService class to be a singleton object and the i18n config to be just a value because we have just configure it. There are more options for lifetime management in the awilix documentation.

Let’s hook our LocaleService and our i18n config together at the constructor so every time we resolve the everything is set up for us:

1. Modify the constructor of the LocaleService class to accept an i18nProvider object:

File: app/services/localeService.mjs

/**
 * LocaleService
 */
export class LocaleService {
  /**
   *
   * @param i18nProvider The i18n provider
   */
  constructor(opts) {
    this.i18nProvider = opts.i18nProvider;
  }
...

2. Test the resolution of our service by replacing the calls in the index.mjs file with the ones using the container.

File: index.mjs

import container from './app/container';
const localeService = container.resolve('localeService');

localeService.getLocales(); // ['en', 'el']
localeService.getCurrentLocale(); // 'en'
localeService.setLocale('el');
console.log(localeService.translate('Hello'));
console.log(localeService.translatePlurals('You have %s message', 3));
$ node --experimental-modules index.mjs
Για Σας
Έχεις 3 μύνηματα

Tip: You can choose to name your resolved service as you like. For this example, we maintained the same name for readability.

Example with Express.js and Mustache.js

Let’s see how can we utilize what we have in an example application using Express.js. Express.js is a small but powerful Web Framework for Node.js that allows you to create a robust set of features for web and mobile applications.

Before we install Express.js though we need to add a few more abstractions on our app in order to accommodate this change.

We need an App class that will accept a Server class object. The App class will know only how to use the server object to start the Application Server and the Server object will know how to start our Express.js application.

Adding Express.js

1. Create an application.mjs file and add a constructor accepting a server object:

$ touch app/application.mjs

File: app/application.mjs

export class Application {
  constructor({ server }) {
    this.server = server;
  }

  async start() {
    await this.server.start();
  }
}

We want the server to start async mode as we need to perform additional tasks when this is resolved

2. Add our Application class to the resolution container:

File: app/container.mjs

import { Application } from './application.mjs';

container
  .register({
    app: awilix.asClass(Application, { lifetime: awilix.Lifetime.SINGLETON })
  })

3. Install Express.js

$ npm install express --save

4 Create a file named server.mjs that contain our initialization logic:

$ touch app/server.mjs

File: app/server.mjs

import express from 'express';

export class Server {
  constructor() {
    this.express = express();
    this.express.disable('x-powered-by');
  }

  start() {
    return new Promise((resolve) => {
      const http = this.express.listen(8000, () => {
        const { port } = http.address();
        console.info(`[p ${process.pid}] Listening at port ${port}`);
        resolve();
      });
    });
  }
}

Our Server currently does nothing but opening a port on 8000 and logging the info.

Let’s hook it up to our Application now:

5. Add the Server class to the resolution container:

File: app/container.mjs

import { Server } from './server.mjs'

container
  .register({
    server: awilix.asClass(Server, { lifetime: awilix.Lifetime.SINGLETON }),
  })

6.  Add this code to resolve the app and start the server:

File: index.mjs

import container from './app/container';
const app = container.resolve('app');

app
  .start()
  .catch((error) => {
    console.warn(error);
    process.exit();
  })

7. Start the server to test that it runs:

node --experimental-modules index.mjs
[p 99922] Listening at port 8000

Adding Mustache.js

Express.js does not have a template rendering engine by default. It is however very customizable and open to extensions. For the purposes of this example, we are going to use a very popular template engine for rendering our translations called Mustache.js

1. Install Mustache and its helper.

$ npm install consolidate mustache --save

2. Configure and add the rendering engine to our Server.js

import express from 'express';
import consolidate from 'consolidate';

export class Server {
  constructor() {
    ...
    // setup mustache to parse .html files
    this.express.set('view engine', 'html');
    this.express.engine('html', consolidate.mustache);
  }
  ...
}

Now we are ready to use our engine to render HTML pages with translatable strings. For that, we have the flexibility to use a middleware function that is supplied from the i18n library. This will inject its API into the req object as provided by the framework so we can use it without importing anything else.

1. Inject the i18nProvider in our server and add the middleware to the Express.js flow.

export class Server {
  constructor({i18nProvider}) {
    ...
    this.express.use(i18nProvider.init);
    this.express.get('/:name?', (req, res) => {
      const name = req.params.name;

      res.render('index', {
        'currentLocale': res.locale,
        'name': name || 'Theo',
        'hello': req.__('Hello'),
        'message': req.__('How are you?')
      });
    });
  }

  start() {
    return new Promise((resolve) => {
      const http = this.express.listen(8000, () => {
        const { port } = http.address();
        console.info(`[p ${process.pid}] Listening at port ${port}`);
        resolve();
      });
    });
  }
}

2. Create our index.html view that will render when we visit the initial path:

$ touch views/index.html

File: views/index.html

<!DOCTYPE html>
<html lang="{{currentLocale}}">
<head>
    <title>Node i18n</title>
<body>
{{hello}} {{name}}<br>
{{message}}
</body>
</html>

3. Run the server and navigate to localhost:8000/  or localhost:8000/:param  to see the following result:

Switching Locale

On the practical side of things, the user of the applications ideally wants to change the locale from the UI. So the application needs to determine which is the current locale based on some client parameters. There are several ways to detect that preference. We can use a cookie that stores the current locale, a query parameter that requests a specific locale or an accept-language header that specifies which languages are which languages the client is able to understand, and which locale variant is preferred.

For the purposes of this tutorial, we are going to use a query parameter as it’s relatively easy to understand and implement.

We need to define a parameter that we will be using in order to determine the current locale setting. Let’s call it lang. Adding it to our app is relevantly simple.

1. Add the lang parameter to our i18nProvider object:

File: app/i18n.config.mjs

import i18n from 'i18n';
import path from 'path';

i18n.configure({
  locales: ['en', 'el'],
  defaultLocale: 'en',
  queryParameter: 'lang',
  directory: path.join('./', 'locales')
});

export default i18n;

2. Add the relevant translations for the target language:

File: locales/el.json

{
   "Hello": "Για Σας",
   "How are you?": "Πώς είστε?",
   "You have %s message": {
      "one": "'Έχεις %s μύνημα",
      "other": "'Έχεις %s μύνηματα"
   }
}

3. Start the Server and navigate to localhost:8000/?lang=el

Adding Template helpers

If you don’t like adding the translation keys and values inside the application code you can include template helpers that parse the keys and automatically apply the translation value directly from the template files. Let’s see how can we do that using Mustache.

1. Add the following middleware function on our Server.mjs file:

File: app/Server.mjs

//https://github.com/janl/mustache.js#functions
this.express.use((req, res, next) => {
  // mustache helper
  res.locals.i18n = () => (text, render) => req.__(text, render);
  next();
});

Here res.locals refers to all the functions available from our mustache.js engine. What we actually do is adding one more template helper for the i18n tag that will just call the req.__ method that is attached from the i18nProvider and by supplying the required parameters.

2. Add extra tags in our index.html file and test that the translations are happening:

File: views/index.html

<!DOCTYPE html>
<html lang="{{currentLocale}}">
<head>
    <title>Node i18n</title>
<body>
{{hello}} {{name}}<br>
{{message}}
<div>
    {{#i18n}}This is a translation helper{{/i18n}}
</div>
</body>
</html>

File: locales/el.json

{
   "Hello": "Για Σας",
   "How are you?": "Πώς είστε?",
   "You have %s message": {
      "one": "'Έχεις %s μύνημα",
      "other": "'Έχεις %s μύνηματα"
   },
   "This is a translation helper": "Αυτός είναι βοηθός μετάφρασης"
}

Now in order to support plural translations using template helpers, we need to provide a different function that will accept 2 tags, one for the message key and one for the count:

File: views/index.html

<!DOCTYPE html>
<html lang="{{currentLocale}}">
<head>
    <title>Node i18n</title>
<body>
{{hello}} {{name}}<br>
{{message}}
<div>
    {{#i18n}}This is a translation helper,{{/i18n}}<br>
    {{#i18np}}You have %s message,{{messageCount}}{{/i18np}}
</div>
</body>
</html>

File: app/Server.mjs

this.express.use((req, res, next) => {
  // mustache helper
  res.locals.i18np = () => (text, render) => {
    const parts = text.split(',');
    if (parts.length > 1) {
      const renderedCount = render(parts[1]);
      return req.__n(parts[0], renderedCount, render)
    }
  };
  next();
});

In that case, we use the render method supplied from mustache to find the replace the correct count from our data store. Just don’t forget to add this key to our response variables:

File: app/server.mjs

this.express.get('/:name?', (req, res) => {
  const name = req.params.name;

  res.render('index', {
    'currentLocale': res.locale,
    'name': name || 'Theo',
    'hello': req.__('Hello'),

messageCount': 5,
    'message': req.__('How are you?')
  });
});

Run again the app to see the result:

 

 

Using our own locale middleware to change language

If you are interested in using our localeService object to detect and set the current locale based on the query parameter then you only have to add the following middleware method to our LocaleService class:

/**
 *
 * @returns {Function} A middleware function to use with Web Frameworks.
 */
getMiddleWare() {
  return (req, res, next) => {
    const queryParameter = 'lang';
    if (req.url) {
      const urlObj = url.parse(req.url, true);
      if (urlObj.query[queryParameter]) {
        const language = urlObj.query[queryParameter].toLowerCase();
        this.setLocale(language);
      }
    }
    next();
  }
}

That way we can re-use our localeService object without cross-referencing other libraries.

I leave as an exercise for the reader the replacement of the calls to translate the strings using our localeService  object instead of the req object in our Server.mjs file.

Phrase

Phrase supports many different languages and frameworks, including Node.js and Javascript. It allows to easily import and export translations data and search for any missing translations, which is really convenient. On top of that, you can collaborate with translators as it is much better to have professionally done localization for your website. If you’d like to learn more about Phrase, refer to the Getting Started guide. You can also get a 14-day trial. So what are you waiting for?

Conclusion

This article made a valiant attempt to describe in detail the steps required in order to add i18n to your Node.js application. I hope you enjoyed the article and that it helped you understand what is required to localize Node.js apps.

This is by no means an exhaustive guide as every application has different needs and scope requirements. Please stay put for more detailed articles regarding this subject.

4.9 (98.75%) 16 votes
Comments
close

The Biggest Mistakes to Watch Out For in Localization

Download our FREE INFOGRAPHIC for a strong overview of the crucial mistakes you need to avoid to ensure your localization process has the best outcome possible.