In our previous discussions, we explored localization strategies for backend frameworks like Rails and Phoenix. Today, we shift our focus to the front-end and talk about JavaScript translation and localization. The landscape here is packed with options, which makes many developers ask: “Which library should I use?” The real answer, even if it’s not the most satisfying, is: “It depends.”
JavaScript localization needs can vary a lot depending on your project, and every library comes with its own strengths. Good software internationalization is key to making the right choice for your app. To help you out, this article gives a clear overview of several popular JavaScript localization libraries, so you can pick the one that fits your project best. Most of these libraries use translation keys to organize multilingual content and keep things consistent across your app.
So, let’s begin our exploration into the world of JavaScript translation and localization. Shall we dive in?
If you are interested in how to translate your HTML easily, please check my other tutorial on the topic.
What is localization and internationalization
Before we dive into libraries, let’s quickly go over two important terms: localization and internationalization.
- Internationalization (often shortened to i18n) is the process of designing and building your app in a way that makes it easy to adapt to different languages, regions, and cultures. It’s about setting up your code to handle things like different date formats, number formats, currencies, plural rules, and text direction (like right-to-left for Arabic). The main idea is: do the hard preparation once, so you don’t need to rip your app apart later when you add new languages.
- Localization (l10n) comes after that. It’s the actual work of translating your app’s text, adjusting layouts if needed, formatting numbers and dates correctly for the user’s region, and even tweaking images or cultural references so everything feels natural to the user.
A common mistake is thinking localization is just translation. It’s not. Localization is about the whole user experience. For example, a joke that works in English might not make any sense in Japanese. Or a “Buy now” button might need different wording or styling depending on the culture. Good localization makes your app feel like it was built for the user, not just translated for them.
In short:
- Internationalization is making your app ready for different languages and cultures.
- Localization is fully adapting your app to a specific audience.
Both are important if you want your app to truly connect with users around the world.
File-based vs fileless approach
When it comes to handling translations in your app, there are two main ways to organize things: file-based and fileless.
- File-based localization means you store your translations in separate files, usually one per language. These files are often in formats like JSON, YAML, or JavaScript modules. Your app loads the right file depending on the user’s language. This approach keeps translation keys and values organized, makes it easy to update them without touching your code, and works great for bigger apps with lots of content.
- Fileless localization means your translations are built directly into your code. Instead of loading a separate file, you define your translation strings right inside your components or scripts. This can be faster and simpler for small projects, but it gets messy quickly if your app grows or needs to support many languages.
In short:
- File-based: clean, scalable, better for bigger or multi-language projects.
- Fileless: quick and simple, better for small apps or quick demos.
Choosing the right approach depends on your project size, how many languages you need, and how you want to manage updates down the road.
JavaScript translation and localization solutions
We will cover these notable solutions:
- Globalize: Well-rooted in the JavaScript ecosystem with strong formatting features.
- I18next: Flexible, extensible, and integrates easily with different frameworks.
- Polyglot.js: A minimalist library from Airbnb, perfect for simpler setups.
- Intl-messageformat: A powerful formatter for text, plurals, dates, and currencies, following the ICU MessageFormat standard. Ideal when you need advanced formatting without a full i18n framework.
We’re focusing on vanilla JavaScript apps here. The goal is to give you a broad overview of translation and localization solutions without diving too deep into the guts of each library—keeping it clear and useful. By the end, you’ll have a good sense of which option might work best for you.
Setting up the project for JavaScript localization
Before we dive into localization libraries, let’s set up a simple project that we can reuse across all examples. We’ll create a Node.js app with a minimal server and a basic template.
First, create a new project folder and initialize it:
mkdir lokalise-i18n-demo cd lokalise-i18n-demo npm init -y
Now install the necessary dependencies:
npm install fastify @fastify/view ejs
fastify
: a fast and lightweight web server@fastify/view
: a plugin to support template enginesejs
: a simple template engine that lets us insert dynamic values into HTML
Open your package.json
, remove the "main": "index.js"
config part and set the type
to "module"
:
"type": "module",
Next, create a basic project structure:
mkdir views touch server.js i18n.js views/index.ejs
Now let’s set up the server inside server.js
:
import Fastify from 'fastify'; import pointOfView from '@fastify/view'; import ejs from 'ejs'; const fastify = Fastify(); // Register EJS as the template engine await fastify.register(pointOfView, { engine: { ejs: ejs }, root: './views', }); fastify.get('/', async (_request, reply) => { const welcomeMessage = 'Welcome, John!'; // Hardcoded for now return reply.view('index.ejs', { message: welcomeMessage }); }); fastify.listen({ port: 3000 }, (err, address) => { if (err) { console.error(err); process.exit(1); } console.log(`Server running at ${address}`); });
Now create the views/index.ejs
file with this content:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Localization Demo</title> </head> <body> <h1><%= message %></h1> </body> </html>
Here’s what happens:
- The server listens on
http://localhost:3000
- When you open that page, the server renders the
index.ejs
template - The
message
variable is inserted into the template using<%= message %>
To run the server:
node server.js
Open your browser and you should see: “Welcome, John!”.
Adding a basic language switcher
Before we dive into actual translations, it’s a good idea to prepare a simple language switcher right from the start. This will allow users to pick a language, and later we’ll hook this selection for real localization.
Right now, we’ll only:
- Accept a
lang
query parameter in the URL - Keep track of the current language
- Render a list of available languages
- Highlight the active one
Open up the i18n.js
file and paste the following code:
export const defaultLocale = 'en'; export const availableLocales = [ { code: 'en', label: 'English', currency: 'USD' }, { code: 'fr', label: 'Français', currency: 'EUR' }, { code: 'he', label: 'עברית', currency: 'ILS' } ]; export function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale); const targetLocale = locale || availableLocales.find(l => l.code === defaultLocale); return targetLocale; }
So, we’ll use English as the default language and support two additional languages. The currency codes will come in handy when we’ll work with prices.
Now update server.js
file:
// ... import { availableLocales, switchLocale } from './i18n.js'; // configuration ... fastify.get('/', async (request, reply) => { const { lang } = request.query; const currentLocale = switchLocale(lang); const welcomeMessage = 'Welcome, John!'; // Still hardcoded return reply.view('index.ejs', { message: welcomeMessage, availableLocales, currentLang: currentLocale.code, }); });
Update views/index.ejs
:
<!DOCTYPE html> <html lang="<%= currentLang %>"> <head> <meta charset="UTF-8"> <title>Localization Demo</title> </head> <body> <ul> <% availableLocales.forEach(function(locale) { %> <li> <% if (locale.code === currentLang) { %> <strong><%= locale.label %></strong> <% } else { %> <a href="/?lang=<%= locale.code %>"><%= locale.label %></a> <% } %> </li> <% }); %> </ul> <h1><%= message %></h1> </body> </html>
- When you open
/
, it defaults to English (en
). - If you open
/?lang=fr
, French (fr
) is selected. - The current language is shown in bold.
- The other languages are shown as links you can click to switch.
Handling RTL (right-to-left) languages
When building apps that support multiple languages, it’s important to handle RTL (right-to-left) languages properly. Languages like Hebrew, Arabic, and Persian are read from right to left, which affects how the entire page layout should behave.
For now, we’ll focus on the basics:
- Detect if the current language is RTL: We’ll extend our
availableLocales
list to include aisRTL
flag for each language. - Set the
dir
attribute on the<html>
tag: If the current language is RTL, we setdir="rtl"
; otherwise, we usedir="ltr"
.
Let’s modify the availableLocales
list in the i18n.js
file like this:
export const defaultLocale = 'en'; export const availableLocales = [ { code: 'en', label: 'English', isRTL: false, currency: 'USD' }, { code: 'fr', label: 'Français', isRTL: false, currency: 'EUR' }, { code: 'he', label: 'עברית', isRTL: true, currency: 'ILS' } ]; export function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale); const targetLocale = locale || availableLocales.find(l => l.code === defaultLocale); return targetLocale; }
Now, inside your route handler (server.js
), simply pass the isRTL
attribute:
import Fastify from 'fastify'; import pointOfView from '@fastify/view'; import ejs from 'ejs'; import { availableLocales, switchLocale } from './i18n.js'; const fastify = Fastify(); await fastify.register(pointOfView, { engine: { ejs: ejs }, root: './views', }); fastify.get('/', async (request, reply) => { const { lang } = request.query; const currentLocale = switchLocale(lang); const welcomeMessage = 'Welcome, John!'; return reply.view('index.ejs', { message: welcomeMessage, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); }); fastify.listen({ port: 3000 }, (err, address) => { if (err) { console.error(err); process.exit(1); } console.log(`Server running at ${address}`); });
And update the html
tag in your template:
<!DOCTYPE html> <html lang="<%= currentLang %>" dir="<%= isRTL ? 'rtl' : 'ltr' %>"> <head> <meta charset="UTF-8"> <title>Localization Demo</title> </head> <body> <ul> <% availableLocales.forEach(function(locale) { %> <li> <% if (locale.code === currentLang) { %> <strong><%= locale.label %></strong> <% } else { %> <a href="/?lang=<%= locale.code %>"><%= locale.label %></a> <% } %> </li> <% }); %> </ul> <h1><%= message %></h1> </body> </html>
Now when you open the page with ?lang=he
, the entire page layout will switch to right-to-left automatically.
JavaScript translation and localization with Globalize
Globalize, developed by the jQuery team, is a powerful solution for translating and localizing JavaScript apps. It uses the Unicode Common Locale Data Repository (CLDR) under the hood, giving it a strong foundation for handling a wide range of localization needs.
Here’s what Globalize can help you with:
- Message formatting: Craft localized messages with ease.
- Date/time parsing: Handle dates and times efficiently, including support for relative times.
- Pluralization support: Accurately manage singular and plural forms.
- Numerical parsing and currency formatting: Work smoothly with numbers and monetary values.
- Unit handling: Convert and format units like days, minutes, and miles per hour.
Globalize ensures consistent performance across both browser and Node.js environments. Its modular architecture means you only load what you need, keeping your application lightweight. Unlike other libraries that hardcode locale data, Globalize allows you to dynamically load CLDR data. This means you can update your localization data on-the-fly without waiting for library updates.
To start using Globalize, check out the Getting started guide which details the installation process.
Installing Globalize
So, it’s time to install Globalize and the extra data it needs.
Globalize doesn’t come alone — it relies on external locale data from the Unicode CLDR (Common Locale Data Repository) to work properly. Thus, we’ll install a few packages at once.
In your project folder, run:
npm install globalize cldr-data iana-tz-data
Here’s what each package does:
globalize
: the main library that handles translations, date formatting, number formatting, and morecldr-data
: provides the raw locale data needed for different languages and regionsiana-tz-data
: optional, but needed if you want to format dates for different time zones (we’ll use it later)
Setting up Globalize in the project
Now that Globalize and its dependencies are installed, let’s hook it into our project. First, we’ll load Globalize and the necessary CLDR data.
Update your i18n.js
like this:
import Globalize from 'globalize'; import pkg from 'cldr-data'; const { entireSupplemental, entireMainFor } = pkg; import timeZoneData from 'iana-tz-data' with { type: 'json' }; Globalize.load(entireSupplemental()); Globalize.loadTimeZone(timeZoneData); export const defaultLocale = 'en'; export const availableLocales = [ { code: 'en', label: 'English', isRTL: false }, { code: 'fr', label: 'Français', isRTL: false }, { code: 'he', label: 'עברית', isRTL: true } ];
Nice!
Creating translation files for Globalize
Globalize lets you load custom translation messages. We’ll prepare the file structure structure now. Inside your project, create a folder called messages
, and inside it, create en.json
, fr.json
, and he.json
:
lokalise-i18n-demo/ ├── messages/ │ └── en.json │ └── fr.json ├── views/ │ └── index.ejs ├── server.js ├── package.json
Add a simple translation to messages/en.json
:
{ "en": { "welcome": "Welcome, {name}!" } }
And French translation:
{ "fr": { "welcome": "Bienvenue, {name} !" } }
And finally he.json
:
{ "he": { "welcome": "ברוך הבא, {name}!" } }
Now we can load these files in our project.
Lazy loading translations from JSON files
Now that we have translation files ready, let’s load our translation messages into Globalize and use them to replace the hardcoded text.
To optimize the process, we’ll lazily load only translations for the chosen locale. Let’s update the switchLocale()
function in the i18n.js
file:
export async function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale) || availableLocales.find(l => l.code === defaultLocale); Globalize.load(entireMainFor(locale.code)); Globalize.locale(locale.code); const messages = await import(`./messages/${locale.code}.json`, { with: { type: 'json' } }); Globalize.loadMessages({ [locale.code]: messages.default[locale.code] }); return { locale, formatMessage: (key) => Globalize.messageFormatter(key) }; }
Note that now we return both the locale and a special function to format our messages.
Next, update your root route to use Globalize to format the message dynamically:
// ... fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, formatMessage } = await switchLocale(lang); const welcomeMessage = formatMessage('welcome')({ name: 'John' }); return reply.view('index.ejs', { message: welcomeMessage, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
What’s happening here:
- Message formatter grabs the
welcome
message from the loaded messages. - We pass
{ name: 'John' }
to replace{name}
inside the message.
Great!
Implementing pluralization with Globalize
Globalize also supports pluralization directly in messages. Let’s provide a new English translation:
{ "en": { "welcome": "Welcome, {name}!", "messages": "You have {count, plural, one {one message} other {{count} messages}}" } }
messages/fr.json
{ "fr": { "welcome": "Bienvenue, {name} !", "messages": "Vous avez {count, plural, one {un message} other {{count} messages}}" } }
messages/he.json
{ "he": { "welcome": "ברוך הבא, {name}!", "messages": "יש לך {count, plural, one {הודעה אחת} other {{count} הודעות}}" } }
Now utilize your formatter to prepare new translated text in the route handler:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, formatMessage } = await switchLocale(lang); const welcomeMessage = formatMessage('welcome')({ name: 'John' }); const messageCount = 3; const messagesInfo = formatMessage('messages')({ count: messageCount }); return reply.view('index.ejs', { message: welcomeMessage, availableLocales, messagesInfo, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Finally, display the new text in the template:
<p><%= messagesInfo %></p>
And now our pluralization is implemented!
JavaScript localization: Dates and times in Globalize
To localize date formats using Globalize, you first need to load the necessary CLDR data related to dates. Then, you can use Globalize to format dates according to the local standards of the user’s region.
Let’s update our i18n.js
file:
export async function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale) || availableLocales.find(l => l.code === defaultLocale); // Load CLDR data dynamically Globalize.load(entireMainFor(locale.code)); Globalize.locale(locale.code); // Load translation messages dynamically const messages = await import(`./messages/${locale.code}.json`, { with: { type: 'json' } }); Globalize.loadMessages({ [locale.code]: messages.default[locale.code] }); // Return full helpers return { locale, formatMessage: (key) => Globalize.messageFormatter(key), formatDate: (date, options) => Globalize.formatDate(date, options) }; }
Now we have our date formatter ready for action.
Update the route handler:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, formatMessage, formatDate } = await switchLocale(lang); const welcomeMessage = formatMessage('welcome')({ name: 'John' }); const messageCount = 3; const messagesInfo = formatMessage('messages')({ count: messageCount }); const currentDate = new Date(); const localizedDate = formatDate(currentDate, { datetime: "medium" }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, localizedDate, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Don’t forget to add your text to the template:
<p><%= localizedDate %></p>
Localizing currency in Globalize
For currency formatting, Globalize can also make your app adaptive to different economic regions. You’ll need to load some currency data and then use Globalize to format numbers as currency values. Update the switchLocale()
function:
export async function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale) || availableLocales.find(l => l.code === defaultLocale); // Load CLDR data dynamically Globalize.load(entireMainFor(locale.code)); Globalize.locale(locale.code); // Load translation messages dynamically const messages = await import(`./messages/${locale.code}.json`, { with: { type: 'json' } }); Globalize.loadMessages({ [locale.code]: messages.default[locale.code] }); // Return full helpers return { locale, formatMessage: (key) => Globalize.messageFormatter(key), formatDate: (date, options) => Globalize.formatDate(date, options), formatCurrency: (amount, currency, options) => Globalize.formatCurrency(amount, currency, options) }; }
Then update your route handler:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, formatMessage, formatDate, formatCurrency } = await switchLocale(lang); const welcomeMessage = formatMessage('welcome')({ name: 'John' }); const messageCount = 3; const messagesInfo = formatMessage('messages')({ count: messageCount }); const currentDate = new Date(); const localizedDate = formatDate(currentDate, { datetime: "medium" }); const priceAmount = 123456.78; // Example price const localizedPrice = formatCurrency(priceAmount, currentLocale.currency); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, localizedDate, localizedPrice, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
And finally the template:
<p><%= localizedPrice %></p>
Globalize: Summary
While the initial setup of Globalize might take some time, it’s a robust solution designed for comprehensive JavaScript application internationalization. Its detailed documentation and flexible features make it an excellent choice for developers aiming to enhance their applications’ global reach.
Streamlining JavaScript translation with I18next
I18next is a comprehensive JavaScript localization framework designed to translate and localize applications efficiently. It’s compatible with multiple front-end frameworks like React, Angular, and Vue, making it versatile for various development environments.
Key features:
- Framework support: Integrates smoothly with major front-end frameworks.
- Format flexibility: Handles different data formats, including Polyglot.
- Dynamic message formatting and pluralization: Easily manage singular and plural forms.
- Resource loading: Load translation data from multiple sources, enhancing flexibility.
- Extensive plugin system: Offers numerous plugins and utilities for advanced localization needs.
Installing I18next for JavaScript localization
For this part, we’ll use the same base project we created earlier in the Setting up the project for JavaScript localization section. That means we already have:
- A basic Node.js server running Fastify
- EJS as the template engine
- A simple language switcher and
switchLocale()
function that picks the current language
Now we’ll add I18next on top of that foundation to start translating messages dynamically. First, install I18next via npm:
npm install i18next i18next-resources-to-backend
We’re also installing a plugin to easily fetch translation files in lazy manner.
Setting up i18next with lazy loading
Now that we installed I18next, let’s hook it properly into our project. We start by updating the i18n.js
file. First, we import I18next and a helper plugin called i18next-resources-to-backend
, which makes it easy to lazy load translation files only when they are needed.
import i18next from 'i18next'; import resourcesToBackend from 'i18next-resources-to-backend';
After that, we initialize I18next. We tell it to use the resourcesToBackend
plugin to dynamically import translation JSON files based on the selected language and namespace. In our case, translations are organized under the locales/
folder.
import i18next from 'i18next'; import resourcesToBackend from 'i18next-resources-to-backend'; export const defaultLocale = 'en'; export const availableLocales = [ { code: 'en', label: 'English', isRTL: false, currency: 'USD' }, { code: 'fr', label: 'Français', isRTL: false, currency: 'EUR' }, { code: 'he', label: 'עברית', isRTL: true, currency: 'ILS' } ]; await i18next .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`, { with: { type: 'json' } }))) .on('failedLoading', (lng, ns, msg) => console.error(`Failed to load ${lng}/${ns}: ${msg}`)) .init({ defaultLocale, fallbackLng: defaultLocale, debug: true, availableLocales, ns: ['main'], defaultNS: 'main' });
Here’s what is happening:
resourcesToBackend
automatically loads translation files on demand.- We handle loading errors by logging them to the console.
- We initialize I18next with a fallback language (
en
) in case the requested language is missing. - We tell I18next that we will use a single namespace called
main
.
Finally, we update the switchLocale()
function. Its job is to switch the language based on the incoming request and return a translation helper function.
export async function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale) || availableLocales.find(l => l.code === defaultLocale); await i18next.changeLanguage(locale.code); return { locale, translate: (key, options = {}) => i18next.t(key, options) }; }
Here’s what happens inside switchLocale()
:
- We check if the requested locale is supported. If not, we fall back to the default locale.
- We tell I18next to switch to the selected language.
- We return both the selected locale and a
translate()
helper function, which simplifies translating keys with optional parameters.
Creating translation files for i18next
I18next uses a concept called “namespaces” to organize translations. Namespaces allow you to split translations into multiple files, which is especially helpful for larger applications where different sections of the app might have their own translations.
In our setup, we are using a single namespace called main
. Therefore, translation files should be structured like this:
locales/ ├── en/ │ └── main.json ├── fr/ │ └── main.json ├── he/ │ └── main.json
Each folder represents a language, and each main.json
file contains the translations for that language.
Let’s provide English translations:
{ "welcome": "Welcome, {{name}}!" }
Note that i18next expects interpolated values to be wrapped into {{ }}
.
French:
{ "welcome": "Bienvenue, {{name}} !" }
Hebrew:
{ "welcome": "ברוך הבא, {{name}}!" }
Each translation mirrors the English key but adapts the text to the target language.
Performing simple JavaScript translation with i18next
With the translations in place, you can now update your main route to use them:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); return reply.view('index.ejs', { message: welcomeMessage, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Here’s what happens:
- We check if a language is provided in the query string.
- We switch to the correct locale and get a translation helper.
- We generate a localized welcome message using the
translate()
function. - We pass all the necessary data to the template for rendering.
Implementing pluralization with i18next
Now let’s see how to handle pluralized messages using I18next.
To start using pluralization, you need to define both singular and plural forms inside your translation files.
Example for English (/locales/en/main.json
):
{ "welcome": "Welcome, {{name}}!", "messages_one": "You have {{count}} message", "messages_other": "You have {{count}} messages" }
Explanation:
- When
count = 1
, I18next usesmessages_one
. - When
count ≠ 1
, I18next usesmessages_other
.
Some languages (like Arabic, Russian, Hebrew) have more than two forms.
In that case, you define multiple keys like:
{ "key_zero": "zero", "key_one": "one item", "key_two": "two items", "key_few": "few items", "key_many": "many items", "key_other": "{{count}} items" }
Having provided your translations for the messages
key, just use the translate
function in the route handler:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = translate('messages', { count: messageCount }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Providing translation context with i18next
Sometimes a single word can have different meanings depending on the situation. For example, gender-specific translations — like “friend” vs “boyfriend” vs “girlfriend”.
I18next handles this easily by using contexts. You create different versions of the key by adding a context suffix:
{ "friend": "A friend", "friend_male": "A boyfriend", "friend_female": "A girlfriend" }
Here:
friend_female
is the translation when context is female.friend
is the neutral/default translation.friend_male
is the translation when context is male.
When calling translate()
(or i18next.t()
), you just pass a context
option:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = translate('messages', { count: messageCount }); const friendInfo = translate('friend', { context: 'female' }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, friendInfo, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
And display as usual:
<p><%= friendInfo %></p>
Localizing date and time in i18next
I18next also makes it easy to localize dates and times. You just define a translation key where the date will be inserted, and pass a JavaScript Date
object during translation. In your English translations (/locales/en/main.json
), add:
{ "intlDateTime": "On the {{val, datetime}}", }
In your server.js
route, you can now translate a date like this:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = translate('messages', { count: messageCount }); const friendInfo = translate('friend', { context: 'female' }); const today = new Date(); const formattedDate = translate('intlDateTime', { val: today, formatParams: { val: { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' } } }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, friendInfo, formattedDate, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
And display it:
<p><%= formattedDate %></p>
Localizing currency in i18next
I18next can also be used to format currencies automatically based on the selected locale and currency type. You define placeholders for currency values inside your translation files and pass numbers when translating.
In your English translations (/locales/en/main.json
), add:
{ "intlCurrencyWithOptionsSimplified": "The value is {{val, currency(USD)}}", "intlCurrencyWithOptions": "The value is {{val, currency(currency: USD)}}", "twoIntlCurrencyWithUniqueFormatOptions": "The value is {{localValue, currency}} or {{altValue, currency}}", }
Here:
{{val, currency(USD)}}
formats the value as a USD amount.{{val, currency(currency: USD)}}
gives you even more flexible control.{{localValue, currency}}
and{{altValue, currency}}
allow formatting two different amounts with potentially different currencies.
Now update your root route to localize and format the currencies:
fastify.get('/', async (request, reply) => { // ... other code ... const price = 2000; const localizedCurrencySimple = translate('intlCurrencyWithOptionsSimplified', { val: price }); const priceAlt = 2300; const localizedCurrencyFull = translate('intlCurrencyWithOptions', { val: priceAlt }); const localValue = 12345.67; const altValue = 16543.21; const localizedTwoCurrencies = translate('twoIntlCurrencyWithUniqueFormatOptions', { localValue, altValue, formatParams: { localValue: { currency: currentLocale.currency, locale: 'en-US' }, altValue: { currency: 'CAD', locale: 'fr-CA' } } }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, friendInfo, formattedDate, availableLocales, localizedCurrencySimple, localizedCurrencyFull, localizedTwoCurrencies, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Here’s what happens:
- We pass the price values to the translation keys.
- We optionally configure specific format options, like setting the currency type and locale.
- We dynamically use the
currentLocale.currency
for the local value, based on the selected language.
Add these lines in index.ejs
:
<p><%= localizedCurrencySimple %></p> <p><%= localizedCurrencyFull %></p> <p><%= localizedTwoCurrencies %></p>
Don’t hesitate to browse other examples in the I18next’s docs on how to enable nesting in translations, work with objects, or setup fallbacks.
I18next: Summary
I18next is not just powerful but also adaptable, fitting well into modern JavaScript projects. While it’s packed with features, its initial setup is simple and the learning curve is moderate, making it an excellent choice for applications requiring robust localization capabilities.
JavaScript localization with Polyglot.js
Polyglot.js is a small JavaScript library for simple localization tasks. Created by Airbnb, it handles string interpolation and pluralization without managing your full translation process. It works in both browsers and Node.js, and is backend-agnostic — meaning you load and manage translations yourself.
Polyglot.js is a good fit for smaller projects that need basic multilingual support without the overhead of a full i18n framework.
Installing Polyglot.js
To add Polyglot.js to your project, simply run:
npm install node-polyglot
At this point, Polyglot is ready to use — you just need to load translations and start working with phrases.
Creating translation files for Polyglot.js
Polyglot.js doesn’t manage loading or organizing translations by itself. It simply expects you to provide a set of phrases when you initialize it or when you extend it later. Because of that, we manually organize our translation files inside a locales/
folder, like this:
locales/ ├── en.json ├── fr.json ├── he.json
Each JSON file will hold the translations for a single language.
Create /locales/en.json
:
{ "welcome": "Welcome, %{name}!" }
Then add /locales/fr.json
:
{ "welcome": "Bienvenue, %{name} !" }
And /locales/he.json
:
{ "welcome": "ברוך הבא, %{name}!" }
Notice that Polyglot expects interpolation variables to use the %{}
syntax by default,
so we stick to %{name}
for inserting the user’s name dynamically.
Lazy-loading translations with Polyglot.js
Since Polyglot doesn’t manage loading translation files for you, we need to handle that ourselves. We will dynamically import the correct JSON file based on the requested locale, and then initialize a Polyglot instance with the loaded phrases.
We update switchLocale()
in the i18n.js
file to do three things:
- Find the right locale (or fallback to English if not found).
- Dynamically import the matching translation file from
/locales/
. - Create and return a configured Polyglot instance along with locale info.
Here’s the updated code:
import Polyglot from 'node-polyglot'; export const defaultLocale = 'en'; export const availableLocales = [ { code: 'en', label: 'English', isRTL: false, currency: 'USD' }, { code: 'fr', label: 'Français', isRTL: false, currency: 'EUR' }, { code: 'he', label: 'עברית', isRTL: true, currency: 'ILS' } ]; export async function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale) || availableLocales.find(l => l.code === defaultLocale); const phrasesModule = await import(`./locales/${locale.code}.json`, { with: { type: 'json' } }); const phrases = phrasesModule.default; const polyglot = new Polyglot({ locale: locale.code, phrases: phrases }); return { locale, polyglot }; }
- We first check if the requested locale is supported. If not, we default to English.
- We lazily import the correct JSON file at runtime.
- We create a new
Polyglot
instance with the loaded phrases and set the locale. - We return both the
locale
object and thepolyglot
instance for use elsewhere.
Performing simple translations with Polyglot.js
In your Fastify route, you’ll now get the polyglot
back from switchLocale()
and use it to translate phrases like this:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, polyglot } = await switchLocale(lang); const welcomeMessage = polyglot.t('welcome', { name: 'John' }); return reply.view('index.ejs', { message: welcomeMessage, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Now the welcome message will be correctly translated based on the selected language.
Handling pluralization with Polyglot.js
Polyglot.js has built-in support for basic pluralization rules. To use plural forms, you define a special translation string that contains multiple forms separated by ||||
(four pipe characters). The library will automatically pick the correct form based on the smart_count
value you pass during translation.
Update your /locales/en.json
like this:
{ "welcome": "Welcome, %{name}!", "messages": "%{smart_count} message |||| %{smart_count} messages" }
And your /locales/fr.json
:
{ "welcome": "Bienvenue, %{name} !", "messages": "%{smart_count} message |||| %{smart_count} messages" }
And your /locales/he.json
:
{ "welcome": "ברוך הבא, %{name}!", "messages": "%{smart_count} הודעה |||| %{smart_count} הודעות" }
Now you can use the smart_count
option when calling polyglot.t()
:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, polyglot } = await switchLocale(lang); const welcomeMessage = polyglot.t('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = polyglot.t('messages', { smart_count: messageCount }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Here:
smart_count
decides whether the singular or plural form is used.- If
smart_count
is 1, Polyglot picks the singular part. - If
smart_count
is 0 or more than 1, it picks the plural part.
Add this line inside your index.ejs
file to display the message info:
<p><%= messagesInfo %></p>
%{smart_count}
must be inside your translation string, otherwise pluralization won’t work. You can also pass the count directly without wrapping it in an object:
polyglot.t('messages', 5);
JavaScript localization: Date and time with Polyglot.js
Polyglot.js focuses only on JavaScript translation and managing plurals. It does not provide any formatting helpers for dates, times, or numbers. For date localization, we simply use the native JavaScript Intl API alongside our translations.
Add a new translation key for date formatting inside /locales/en.json
:
{ "today": "Today is %{date}" }
French /locales/fr.json
:
{ "today": "Nous sommes le %{date}" }
Hebrew /locales/he.json
:
{ "today": "היום הוא %{date}" }
Inside your route handler, you use Intl.DateTimeFormat
to format the date for the current locale,
then pass it into the translation:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, polyglot } = await switchLocale(lang); const welcomeMessage = polyglot.t('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = polyglot.t('messages', { smart_count: messageCount }); const today = new Date(); const formattedDate = new Intl.DateTimeFormat(currentLocale.code, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(today); const todayInfo = polyglot.t('today', { date: formattedDate }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, todayInfo, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
In your index.ejs
, display the localized date:
<p><%= todayInfo %></p>
Polyglot.js: Summary
Polyglot.js is perfect for apps that need basic translations, interpolation, and pluralization without the weight of a full i18n framework. It’s great when you want a tiny, easy-to-use library andr ready to manage translations manually. It’s ideal for small to medium projects where full-scale i18n solutions would be overkill.
JavaScript localization and message formatting with FormatJS
FormatJS is a modular library for rich text formatting in JavaScript apps.
Its core piece, intl-messageformat, lets you handle:
- Plurals
- Genders
- Select cases
- Nested messages
It follows ICU MessageFormat standards but doesn’t manage translation loading — you handle that yourself. Great for apps needing powerful, flexible formatting without the weight of a full i18n framework.
Installing intl-messageformat
To start using FormatJS for message formatting, install the intl-messageformat
package:
npm install intl-messageformat
That’s it — ready to format messages manually based on the user’s locale.
Creating translation files for intl-messageformat
Intl-messageformat only formats messages — it doesn’t manage translation loading. Just like with Polyglot.js, we handle storing and loading translations ourselves.
We’ll organize the translation files inside a locales/
folder:
locales/ ├── en.json ├── fr.json ├── he.json
Each file will store plain message strings following ICU MessageFormat syntax.
Create /locales/en.json
:
{ "welcome": "Welcome, {name}!" }
Then /locales/fr.json
:
{ "welcome": "Bienvenue, {name} !" }
And /locales/he.json
:
{ "welcome": "ברוך הבא, {name}!" }
Lazy-loading translations with intl-messageformat
We’ll update switchLocale()
so it:
- Selects the correct locale (or defaults to English).
- Dynamically loads the right JSON file.
- Prepares a simple translation function using
IntlMessageFormat
.
Here’s the full updated code:
import { IntlMessageFormat } from 'intl-messageformat'; export const defaultLocale = 'en'; export const availableLocales = [ { code: 'en', label: 'English', isRTL: false, currency: 'USD' }, { code: 'fr', label: 'Français', isRTL: false, currency: 'EUR' }, { code: 'he', label: 'עברית', isRTL: true, currency: 'ILS' } ]; export async function switchLocale(requestedLocale) { const locale = availableLocales.find(l => l.code === requestedLocale) || availableLocales.find(l => l.code === defaultLocale); const messagesModule = await import(`./locales/${locale.code}.json`, { with: { type: 'json' } }); const messages = messagesModule.default; function translate(key, values = {}) { const message = messages[key]; if (!message) return key; // fallback if key not found const formatter = new IntlMessageFormat(message, locale.code); return formatter.format(values); } return { locale, translate }; }
- We lazily import the correct
main.json
based on requested language. - We define a
translate()
helper function:- It looks up the message key.
- It creates a new
IntlMessageFormat
instance for that message. - It formats the message using optional interpolation values.
- We fallback to returning the key itself if translation is missing (basic safe default).
Performing JavaScript translation with intl-messageformat
Now let’s update our root route:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); return reply.view('index.ejs', { message: welcomeMessage, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
This is it!
Handling pluralization with intl-messageformat
intl-messageformat natively supports ICU MessageFormat syntax, which makes pluralization super simple.
You just define plural forms inside your translation strings, and pass a count
value when formatting.
Make sure your /locales/en.json
includes a pluralized message:
{ "welcome": "Welcome, {name}!", "messages": "{count, plural, one {You have one message} other {You have {count} messages}}" }
Same for /locales/fr.json
:
{ "welcome": "Bienvenue, {name} !", "messages": "{count, plural, one {Vous avez un message} other {Vous avez {count} messages}}" }
And for /locales/he.json
:
{ "welcome": "ברוך הבא, {name}!", "messages": "{count, plural, one {יש לך הודעה אחת} other {יש לך {count} הודעות}}" }
You don’t need to change switchLocale()
— because our translate()
function already works for any message, including plurals.
In your Fastify route, add pluralized usage like this:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = translate('messages', { count: messageCount }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
In index.ejs
, render the pluralized message:
<p><%= messagesInfo %></p>
count
is passed into the message, and ICU syntax handles the switching between singular and plural forms.- If needed, ICU also supports more complex plural cases like
zero
,few
,many
, but basicone/other
is enough for most languages like English and French. - No extra logic needed — just make sure your translations are correctly written.
Localizing dates with intl-messageformat
Intl-messageformat also supports formatting dates and times directly inside your messages, following ICU standards. No need to manually format with Intl.DateTimeFormat
— you just pass a Date
object during translation.
Add a new key to your /locales/en.json
:
{ "welcome": "Welcome, {name}!", "messages": "{count, plural, one {You have one message} other {You have {count} messages}}", "today": "Today is {date, date, long}" }
Same for /locales/fr.json
:
{ "welcome": "Bienvenue, {name} !", "messages": "{count, plural, one {Vous avez un message} other {Vous avez {count} messages}}", "today": "Nous sommes le {date, date, long}" }
And /locales/he.json
:
{ "welcome": "ברוך הבא, {name}!", "messages": "{count, plural, one {יש לך הודעה אחת} other {יש לך {count} הודעות}}", "today": "היום הוא {date, date, long}" }
Now you can pass the Date
object directly into translate()
:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = translate('messages', { count: messageCount }); const todayInfo = translate('today', { date: new Date() }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, todayInfo, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Add this to index.ejs
:
<p><%= todayInfo %></p>
{date, date, long}
uses ICU formatting types. You can customize it:short
– 1/1/21medium
– Jan 1, 2021long
– January 1, 2021full
– Friday, January 1, 2021
intl-messageformat
takes care of formatting correctly based on the active locale.
Localizing currencies with intl-messageformat
Just like dates, currencies are natively supported in ICU message syntax with intl-messageformat. You pass a number and currency code, and it formats everything based on the locale.
Add currency-related key to /locales/en.json
:
{ "welcome": "Welcome, {name}!", "messages": "{count, plural, one {You have one message} other {You have {count} messages}}", "today": "Today is {date, date, long}", "price": "The total is {amount, number, ::currency/USD}" }
Same for /locales/fr.json
:
{ "welcome": "Bienvenue, {name} !", "messages": "{count, plural, one {Vous avez un message} other {Vous avez {count} messages}}", "today": "Nous sommes le {date, date, long}", "price": "Le total est de {amount, number, ::currency/EUR}" }
And for /locales/he.json
:
{ "welcome": "ברוך הבא, {name}!", "messages": "{count, plural, one {יש לך הודעה אחת} other {יש לך {count} הודעות}}", "today": "היום הוא {date, date, long}", "price": "הסכום הכולל הוא {amount, number, ::currency/ILS}" }
Notice:
- We use
{amount, number, ::currency/USD}
format. - You specify which currency to show directly inside the message.
Use it exactly like any other key:
fastify.get('/', async (request, reply) => { const { lang } = request.query; const { locale: currentLocale, translate } = await switchLocale(lang); const welcomeMessage = translate('welcome', { name: 'John' }); const messageCount = 3; const messagesInfo = translate('messages', { count: messageCount }); const todayInfo = translate('today', { date: new Date() }); const totalPrice = 1234.56; const priceInfo = translate('price', { amount: totalPrice }); return reply.view('index.ejs', { message: welcomeMessage, messagesInfo, todayInfo, priceInfo, availableLocales, currentLang: currentLocale.code, isRTL: currentLocale.isRTL }); });
Add this line in index.ejs
:
<p><%= priceInfo %></p>
- ICU formatting will handle decimal separators, symbol placement, and number formats properly for each locale.
- No extra libraries needed — all formatting is built into Intl API and intl-messageformat parsing.
Intl-messageformat: Summary
Intl-messageformat is a good fit when you need powerful text, plural, date, and currency formatting without using a full internationalization framework. It’s lightweight, flexible, and lets you fully control how and when translations are loaded. Perfect for apps that want rich ICU-style formatting but stay modular and simple.
Translate JavaScript apps with Lokalise!
Supporting multiple languages on a big website may become a serious pain. You must make sure that all the keys are translated for each and every locale. Luckily, there is a solution to this problem: the Lokalise platform that makes working with the localization files much simpler. Let me guide you through the initial setup which is nothing complex really.
- To get started, grab your free trial
- Create a new project, give it some name, and set English as a base language
- Click “Upload”
- Upload translation files for all your languages
- Proceed to the project, and edit your translations as needed
- You may also contact professional translator to do the job for you
- Next simply download your files back
- Profit!
Lokalise has many more features including support for dozens of platforms and formats, and even the possibility to upload screenshots in order to read texts from them. So, stick with Lokalise and make your life easier!
Translate and localize JavaScript frameworks
While this article focuses on translating plain JavaScript apps, we also have separate tutorials covering how to handle localization in popular JavaScript frameworks. If you’re working with React, Angular, Vue, or others — check out these resources:
React and Next
- React i18n: A step-by-step guide to React-intl — learn how to introduce internationalization into React apps using
react-intl
. Covers key topics like storing translations, adding a language switcher, localizing dates, times, currencies, and handling pluralization. - How to internationalize a React application using i18next — a practical guide for setting up i18next in React projects, explaining the core concepts and providing step-by-step examples.
- Next.js localization: A step-by-step guide with examples — Ddiscover how to translate your Next.js applications easily with NextGlobeGen. Covers project setup, translation management, and best practices.
Angular
- Angular i18n: Performing translations with a built-in module — learn how to implement internationalization using Angular’s built-in i18n support. Includes working with XLIFF translation files and supporting multiple languages, updated for Angular v11+.
- Angular localization with Transloco — explore an alternative approach using Transloco, a powerful Angular library for handling translations. Covers dynamic loading, language switching, and storing user preferences.
Vue and Nuxt
- Vue 3 i18n: Building a multi-lingual app — deep dive into setting up multilingual support in Vue 3 using
vue-i18n
. Learn about auto-detecting user locales, storing language settings, lazy-loading translations, and integrating with Vue Router. - Vue 2 i18n: A complete tutorial for Vue 2 projects — walks through the basics and more advanced topics like localized routing and creating a custom translation plugin.
- Nuxt i18n: Translate your Nuxt.js app into multiple languages — Learn how to set up internationalization in Nuxt.js projects using the
@nuxtjs/i18n
module.
Other JavaScript frameworks
- Svelte i18n: A step-by-step guide — see how to prepare your Svelte application for localization. Covers creating and loading translations, detecting languages, and switching between locales dynamically.
- EmberJS i18n: A beginner’s guide — Get started with translating Ember apps. Learn how to organize translation files, switch languages, and handle pluralization.
- Translating Aurelia applications with Aurelia i18n — understand how to add multilingual support in Aurelia using the official
aurelia-i18n
plugin.
Conclusion
In this article, we explored a range of tools for translating and localizing JavaScript applications. From the full-featured Globalize and I18next libraries to the lightweight efficiency of Polyglot.js and intl-messageformat, we looked at different solutions, each suited to specific project needs.
Each library comes with its own strengths, trade-offs, and level of complexity. Choosing the right one depends heavily on what your project demands — whether it’s multi-framework support, simplicity, advanced localization features, or something in between.
Hopefully, this overview gave you a solid starting point to pick an internationalization (i18n) tool that fits your goals. It’s always a good idea to research carefully and experiment early, because switching localization frameworks halfway through a project can be messy and expensive.
Thanks for following along on this dive into JavaScript translation and localization. Until next time — happy coding, and may your apps speak fluently to the whole world!