When you have a successful application sooner or later, you need to find new sources of growth. It turns out that one of the most productive sources of new users is localization of your app. Therefore, if you start developing your app with localization in mind, it dramatically reduces potential pain. That’s why even if you not planning to support a multi-language interface right now, it is a good idea to know how you can quickly create a localization solution with Node.js i18n libraries.
Node.js is a popular cross-platform open-source JavaScript runtime environment. Traditionally JavaScript was evolving as an in-browser language and Node.js opened a broad range of other valuable applications. That’s why many services are now using JavaScript or languages that are transcompiled into JavaScript as a backend language. As JavaScript has some shortcomings popularized by many online meme pictures, several such languages appear. Notable examples are TypeScript and CoffeeScript. JavaScript also doesn’t stand still, and new, more modern specifications of the scripting language appear.
If you are wondering what i18n is about it’s just an abbreviation of the word “internationalization”. For this reason, often programming libraries to keep names short use this abbreviation. Therefore, Node.js i18n simply means Node.js internationalization. In this particular article, we are going to cover i18next library which enables Node.js i18n.
Installing and Configuring Express.js
Let’s start with creating a primitive application based on Express.js by using an express-generator to scaffold it. A small disclaimer: in this article I’m going to cover mostly translation staff. So if you are going to create some production application, I encourage you to follow guidelines and practices to use patterns, extract different logic to separate files, use inheritance etc.
Let’s install express-generator globally and create a new app:
npm install -g express-generator
express --view=pug book_shop
The command creates a folder with the following structure:
bin
folder contains start script.node_modules
is a legendary black hole with all installed Node.js libraries for your application.public
here lives your static files like robots.txt, CSS, or images.routes
here we map paths of URI to the logic inside your application.views
the name says for itself.app.js
is the root file of your application.package.json
contains meta-information about your application.
First, you need to install all packages listed in package.json
along with their dependencies. Do it with the following command:
cd book_shop && npm install
To start your application type:
npm start
By default the web server starts on TCP port 3000. Therefore, open your browser and navigate to:
http://localhost:3000
You may change something in the views/index.pug
file, for example add:
p It's a translation article application
Installing Node.js I18n Library: I18next
Next, we want to have some sort of a Node.js i18n library. Such libraries are helping us with a few things:
- Manage language switching.
- Working with language resource files. Typically we have some labels in our application that define parts where some text should be translated in a specific language. Translation libraries on some stage of application work replace these labels with text from resource files.
- Providing translation functions or helpers which help us implement translation logic.
- Understands formats of resource files, allow us to use embedded variables and nesting.
- Use pluralization formats, where forms of words change depends on object counts.
- Some of them automatically translate dates, weekdays, months, and other standard things.
In this article we are going to use I18next: a full-fledged internationalization framework supporting both vanilla JavaScript applications, client-side frameworks, and Node.js.
Let’s start by installing the necessary modules:
npm install i18next --save
npm install i18next-express-middleware --save
npm install i18next-node-fs-backend --save
Translation Management System
Using some kind of an Node.js I18n library will greatly help you as a developer. However, doing the actual translations into multiple languages will still be very painful, especially for complex apps. You will need to make sure that your translators do not edit the same file simultaneously, that translation values are present for each language, that interpolation is done properly etc. To further enhance the process of translating your app, a solid translation management system is required. Meet Lokalise: a next generation service which will immensely help you with all the listed problems (and much more!).
To get started, grab your free trial.
You may use either graphical user interface to work with Lokalise, or a command-line interface. If you prefer the latter, download and install Lokalise CLIv2. Next, go to yourĀ profile page, open the āAPI tokensā section, and create a read/write token. This token will be used later to upload and download translation files.
Lastly, create a new project, give it a name, and select English as a base language (the language that you are going to translate from).
Setting Up I18n Library: I18next
To configure i18next and all necessary packages, we need to change the app.js
file. Again, I’m a big fan of decomposing each general file into purpose-specific small ones. But to keep it simple and not overwhelm you with unrelated details, I’ll do everything most straightforwardly.
Import the i18next library:
const i18next = require('i18next');
Import i18next-express-middleware to avoid writing a custom middleware component:
const i18nextMiddleware = require('i18next-express-middleware');
Middleware functions allow us to insert some logic somewhere between an HTTP request and an HTTP response. A middleware has access to the request object, response object, and the next function. It can write some data to the response object, let’s say HTTP response code like 500 Server Error and halt further steps. This concept is widespread for web server development. With middleware, you can do such things as authentication, access control, logging, and service parameter processing. In our case i18next-express-middleware allow us to parse parameters to set the current language automatically, wire up i18next data to request object, add localized routes.
We are planning to work with resource files from the filesystem so import the corresponding module:
const Backend = require('i18next-node-fs-backend');
Next, we need to initialize everything and configure it.
Configuring I18next Localization Library
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
i18next | |
.use(Backend) | |
.use(i18nextMiddleware.LanguageDetector) | |
.init({ | |
backend: { | |
loadPath: __dirname + '/locales/{{lng}}/{{ns}}.json', | |
}, | |
detection: { | |
order: ['querystring', 'cookie'], | |
caches: ['cookie'] | |
}, | |
fallbackLng: 'en', | |
preload: ['en', 'ru'] | |
}); |
The backend block is in charge of loading language file resources. loadPath
tells where files are. Here we see two confounding variables ns
and lng
. lng
is the language code, and by default, it’s undefined
. ns
is a namespace, and the default value is translation
. Namespaces may be useful in some cases, like having separate language files.
detection
parameters tell the middleware where to look for a language to set as current language. In our case, middleware checks the query string first, and if there is no language parameter provided, it goes to cookies. As we can see from the caches
parameter, the middleware saves an active language in cookies. fallbackLng
sets the language, if no active language parameters found anywhere. preload
makes listed languages load on the spot. If you have large files, sometimes it’s better to load a specific language later in the controller. You can make it anytime if you use i18next.loadLanguages
method.
If something goes wrong and you don’t know why – set debug: true
to enable debugging.
app.use(i18nextMiddleware.handle(i18next));
This string integrates our middleware into our app workflow.
Finally, our app.js file looks like this:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var createError = require('http-errors'); | |
var express = require('express'); | |
var path = require('path'); | |
var cookieParser = require('cookie-parser'); | |
var logger = require('morgan'); | |
var indexRouter = require('./routes/index'); | |
var usersRouter = require('./routes/users'); | |
var app = express(); | |
const i18next = require('i18next'); | |
const i18nextMiddleware = require('i18next-express-middleware'); | |
const Backend = require('i18next-node-fs-backend'); | |
i18next | |
.use(i18nextMiddleware.LanguageDetector) | |
.use(Backend) | |
.init({ | |
backend: { | |
loadPath: __dirname + '/locales/{{lng}}/{{ns}}.json' | |
}, | |
debug: true, | |
detection: { | |
order: ['querystring', 'cookie'], | |
caches: ['cookie'] | |
}, | |
preload: ['en', 'ru'], | |
saveMissing: true, | |
fallBackLng: ['en'] | |
}); | |
app.use(i18nextMiddleware.handle(i18next)); | |
// view engine setup | |
app.set('views', path.join(__dirname, 'views')); | |
app.set('view engine', 'pug'); | |
app.use(logger('dev')); | |
app.use(express.json()); | |
app.use(express.urlencoded({ extended: false })); | |
app.use(cookieParser()); | |
app.use(express.static(path.join(__dirname, 'public'))); | |
app.use('/', indexRouter); | |
app.use('/users', usersRouter); | |
// catch 404 and forward to error handler | |
app.use(function(req, res, next) { | |
next(createError(404)); | |
}); | |
// error handler | |
app.use(function(err, req, res, next) { | |
// set locals, only providing error in development | |
res.locals.message = err.message; | |
res.locals.error = req.app.get('env') === 'development' ? err : {}; | |
// render the error page | |
res.status(err.status || 500); | |
res.render('error'); | |
}); | |
module.exports = app; |
Creating a Controller
Now we need to use our new localization library in the application code. Let’s do a little grooming to extract logic from routers to controllers. We are creating a controllers
folder and a index_controller.js
file inside:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var express = require('express'); | |
var router = express.Router(); | |
const i18next = require('i18next'); | |
class IndexController { | |
static list(req, res, next) { | |
res.render('index', { title: 'Express'}); | |
} | |
} | |
module.exports = IndexController; |
First, we import express and router libraries to make sure the Express.js logic works. Next, we import i18next to play with it.
We make a class that contains methods for processing requests. In the class, we are creating a list method with parameters we take from the router function. req
is the request object and res
is a response object. Our middleware extends this object with custom i18next parameters that we can use. We can refer to i18next with req.i18n
inside the class. Later we’ll see what can be achived with it.
The next thing we pay attention to is index.pug
. PUG is a template language, which is lexically compact and very easy to read. You can use any convenient template language if you want. We use PUG to emphasize essential parts and don’t pay attention to HTML code.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
extends layout | |
block content | |
h1 Welcome to #{title} | |
p It's a translation article application |
As you can see, PUG use indentation to define code blocks. #{title}
is a variable that we pass from the controller’s render function, as you can see from our controller. To glue everything together, we connect our controller to the router:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
var express = require('express'); | |
var router = express.Router(); | |
const IndexContoller = require('../controllers/index_controller'); | |
/* GET home page. */ | |
router.get('/', IndexContoller.list); | |
module.exports = router; |
Language Resource Files
Let’s also create resource files for each language we are going to work with. According to our configuration, we must create locales folders and two subfolders: ru
and en
(they are going to contain Russian and English translations respectively). Inside we should place files with the same names: translation.json
. If we change our namespace to some other more suitable name, then our translation file should have the same name. The format of the files is standard key-value JSON. i18next
also supports nesting for logical structuring:
{ home: { description: 'Neque porro quisquam est qui
dolorem ipsum quia dolor sit amet, consectetur,Ā adipisci velit" } }
In this case, to access the description
nested key, we would use home.description
.
locales/en/translation.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"home": { | |
"title": "Book shop", | |
"total": "There are {{counter}} books in the list", | |
"buttonText": "Refresh button", | |
"buttonHTML": "<button onClick='window.location.reload();'>$t(buttonText)</button>", | |
} | |
} |
locales/ru/translation.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"home": { | |
"title": "ŠŠ½ŠøŠ¶Š½Ńй магазин", | |
"total": "ŠŃего в ŃŠæŠøŃŠŗŠµ {{counter}} книг", | |
"buttonText": "ŠŠ±Š½Š¾Š²ŠøŃŃ ŃŃŃŠ°Š½ŠøŃŃ", | |
"buttonHTML": "<button onClick='window.location.reload();'>$t(buttonText)</button>", | |
}, | |
"Something for Nothing": "ŠŠ¾Šµ ŃŃŠ¾ Š·Š°Š“Š°ŃŠ¾Š¼", | |
"Harry Potter and the Philosopher's Stone": "ŠŠ°ŃŃŠø ŠŠ¾ŃŃŠµŃ Šø Š¤ŠøŠ»Š¾ŃŠ¾ŃŃŠŗŠøŠ¹ каменŃ" | |
} |
Uploading to Lokalise
Once your translation files have some initial content, you may upload them to your new Lokalise project:
lokalise2 file upload --token <token> --project-id <project_id> --lang-iso en --file locales/en/translation.json
lokalise2 file upload --token <token> --project-id <project_id> --lang-iso ru --file locales/ru/translation.json
We obtained token
in one of the previous sections. As for the project_id
, it can be found by clicking “More” button and clicking “Settings” while on the project’s page.
If you prefer to use GUI, then simply navigate to your project and click the “Upload” button as instructed in the documentation. Next, add your translation files and press “Import the files”. After a couple of seconds, you will be good to go!
Now you may translate your strings, add more translation keys, languages, and much more.
Language Switching
Now we have everything in the place but how can a user change languages in our app? Our middleware should change the current language if we provide the link with the proper query string. Let’s make two links for each language and translate our title into Russian language using the t
helper:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
extends layout | |
block content | |
h1 Welcome to #{title} | |
p Welcome #{t("home.title")} | |
p It's a translation article application | |
a(href='?lng=en') English <br> | |
a(href='?lng=ru') Russian |
Now we have a link, and our translation works. What if we don’t want to have a persistent query parameter in the URL field of a browser? Conveniently, there is an option for this. Just change the string of middleware integration:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
app.use( | |
middleware.handle(i18next, { | |
ignoreRoutes: ["/foo"], // or function(req, res, options, i18next) { /* return true to ignore */ } | |
removeLngFromUrl: false | |
}) | |
); |
Here you also can ignore some specific routes if you don’t want trigger language switching there.
Using the translation helper
Let’s create a little bit more complex page structure and introduce a super simple table. Create an array with books in the index_controller
. First, we need to write a table using pug and to make it readable ā style it a little bit:
index.pug
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
extends layout | |
block content | |
h1 Welcome to #{title} | |
p Welcome #{t("home.title")} | |
p It's a translation article application | |
a(href='?lng=en') English <br> | |
a(href='?lng=ru') Russian | |
<br> | |
table | |
th Book | |
th Author | |
each book in books | |
tr | |
td= t(book.title) | |
td= t(book.author) | |
tr | |
td= t('home.total', { counter: counter }) |
style.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
body { | |
padding: 50px; | |
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; | |
} | |
a { | |
color: #00B7FF; | |
} | |
table { | |
width: 100%; | |
border-collapse: collapse; | |
} | |
table th { | |
background-color: #f2f2f2; | |
} | |
th, tr, td, { | |
border: 1px solid black; | |
} | |
th { | |
height: 50px; | |
} |
What if we use not simple keys, but the whole sentence like book names? As you see from this code, it works without any problem. If we don’t have a translation, you just have your keys displayed.
I18next supports the following formats of resource strings:
{'simpleString': 'Simple string'}
the most common case.{'simpleNestedString': 'This is simple string that contains $t(simpleString)}
we can nest translations with$t
.{'simpleStringWithVariable': 'Simple string with {{ variable1 }} and {{variable2}} }
to display variable we pass it as a second parameter. For example:
var a = 'first';
var b = 'second';
req.i18n.t('simpleStringWithVariable', { variable1: a, variable2: b } )
.
{'simpleStringWithNumeral': '{{count}} apple}, { 'simpleStringWithNumber_plural': '{{count}} apples}
with postfix_plural
we can define a situation when you have several objects and want to set a proper declension.- Similarly, we can provide the context parameters
{ 'key_male': 'He is good', 'key_female': 'She is good' }
to utilize this feature just callt
with context-paramt('key', { context: 'male' })
. You may combine context with plurals.
- You can also use arrays in resource files.
{ 'key': [ { 'name': 'Roman' }, { 'name': 'Elena' } ]}
array elements can refer by using the number of an element as an attributekey.0.name
will produceRoman
.
Translations in Code
What if we must translate something not in the template, but inside the code? As we’ve previously noted, we have i18next object embedded into request object by our middleware.
So you can perform translations in the controller by using the t
method: req.i18n.t("home.title")
.
t
function is a powerful instrument that we can use in several ways:
t('key')
resolve a key.t('key.subkey')
we are searching for a nested subkey.t('common:key')
resolve a key in a namespace called “common”.t(['error.404', 'error.unspecified'])
we are searching for the first key; if the first key undefined, we are looking for a second and so forth.
One more thing. What if we pass an insecure HTML code to the i18next interpolation? Fortunately, the library escapes all strings. If you want to display insecure content like HTML code you can use following notations inside translation strings:
var HTML = <button onClick=<button onlick="window.location.reload();">Refresh page</button>
{{- html }}
produces insecure output HTML.
{{ html }}
produces secure string output.
Downloading Translation Files From Lokalise
After you’ve edited your translations on Lokalise, download them back by running:
lokalise2 file download --token <token> --project-id <project_id> --directory-prefix locales/%LANG_ISO% --format json
This is going to download translation files back in JSON format and place them in locales/<LANG_ISO>
folder inside the current folder. Take a look at the official documentation for the command-line interface to read about other commands and options.
If you are using GUI, press the “Download” button and adjust the following options:
- Format – JSON
- File structure – Use previously assigned filenames with the following directory prefix:
locales/%LANG_ISO%
Adjust other additional options as needed. Once you are ready, press the “Build and download” button. You will get an archive containing all your translation keys and values which may be used in the application!
Conclusion
Node.js is a perfect solution for building web applications. It has a high performance, vast ecosystem, great community, a high number of different purpose libraries. As JavaScript as a language now has coverage more than ever, many JavaScript libraries implement state of the art ideas.
You can build fairly complex translation solutions with Node.js libraries. I18next Translation framework is an excellent solution with very few shortcomings. That’s why it’s a good choice for Node.js i18n and localizing Node.js apps. There are tons of different options for each library’s function that you can browse in the documentation. Support for many platforms, resource sources, middlewares are in place. Even debug output is very well developed and contains much useful information about each action the library makes. The translation is available both in code and templates. Moreover, translation functions and helpers have many useful options. Resource files support nesting. The only thing that looks a little bit disappointing is plurals support, but you can use custom context to mitigate this issue.