Translating JavaScript apps

Libraries and frameworks to translate JavaScript apps

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 engines
    • ejs: 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 a isRTL flag for each language.
    • Set the dir attribute on the <html> tag: If the current language is RTL, we set dir="rtl"; otherwise, we use dir="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 more
    • cldr-data: provides the raw locale data needed for different languages and regions
    • iana-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 uses messages_one.
    • When count ≠ 1, I18next uses messages_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 the polyglot 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 basic one/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/21
      • medium – Jan 1, 2021
      • long – January 1, 2021
      • full – 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

    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

    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!

    Related articles
    A balance with examples of code for technical debt and time/money on the other side

    Technical debt is what happens when developers take shortcuts to deliver software faster, knowing they’ll need to fix things later. It’s like cutting corners when building a house—maybe you skip…

    Updated on March 17, 2025
    Stop wasting time with manual localization tasks. 

    Launch global products days from now.