Crowdin Logo - Dark Blog

How to Localize JavaScript and React Apps with Lingui

Last updated: 13 min read
How to Localize JavaScript and React Apps with Lingui

Internationalization (i18n) prepares your apps for a global audience in today’s interconnected world. If you’re a JavaScript or React developer looking for a lightweight, powerful i18n tool, you might like Lingui.

What is Lingui?

Lingui is an i18n framework for JavaScript, React.js (including RSC), Next.js, Node.js, Angular, Vue, Svelte, and more. Its simple syntax makes it easy to get started with, and should be familiar to users of other React i18n libraries. Lingui uses the popular ICU MessageFormat to represent translations, and it uses the PO (gettext) and JSON formats to store translations.

Lingui has a CLI tool with helpful commands for extracting messages for translation, tracking the number of translated messages, and compiling messages. Lingui’s ecosystem also includes a number of plugins such as SWC Plugin, Vite Plugin, and ESLint Plugin.

To demonstrate Lingui’s capabilities, we’ll localize the Statistics page of a fictional game. You can find the complete source code on GitHub.

Sample application for displaying game statistics for each player

The sample application is built using the Vite CLI and its React template. If you want to install Lingui on another React project that doesn’t use Vite, follow this guide on the Lingui website. It also uses Tailwind CSS for styling, but you can use any CSS framework you prefer.

Let’s install Lingui in the app.

Installing Lingui for React

First, install the necessary Lingui packages:

Terminal window
npm install --save-dev @lingui/cli @lingui/core
npm install --save @lingui/react

Also, since we’re using Vite, we need to install @lingui/vite-plugin:

Terminal window
npm install --save-dev @lingui/vite-plugin

Add the plugin to the Vite configuration:

vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { lingui } from "@lingui/vite-plugin";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: ["@lingui/babel-plugin-lingui-macro"]
}
}),
tailwindcss(),
lingui()
]
});

Then, create the Lingui configuration file lingui.config.js in the root folder of your project:

lingui.config.js
import { defineConfig } from "@lingui/cli";
export default defineConfig({
sourceLocale: "en",
locales: ["en", "fr"],
catalogs: [
{
path: "src/locales/{locale}/messages",
include: ["src"]
}
]
});

In this project, we’ll use English and French as locales. English is the source locale, so we’ll translate from English to French.

The path field tells Lingui where to store message catalogs for each locale. It will store the message catalogs for French in src/locales/fr/messages and the message catalogs for English in src/locales/en/messages. By default, Lingui uses the PO format to store our translations.

Finally, add the following command line scripts to package.json:

package.json
{
"scripts": {
"extract": "lingui extract",
"compile": "lingui compile"
}
}

These commands are an important part of Lingui’s internationalization and localization workflow. They’re responsible for extracting messages from the source code and compiling them so they’re ready for use in the app. Let’s start with a simple translation to see how they work.

Translating Simple Messages

Before we start our journey, let’s create a simple i18n helper in src/i18n.js:

src/i18n.js
import { i18n } from "@lingui/core";
export async function loadCatalog(locale) {
const { messages } = await import(`./locales/${locale}/messages.po`);
i18n.loadAndActivate({ locale, messages });
}

This helper exports a function that loads and enables messages for a given locale. We’ll use it to dynamically load messages when the user changes the locale.

Components need to read information about current language and message catalogs from the i18n instance. Lingui uses the I18nProvider to pass the i18n instance to your React components.

Let’s update the src/main.jsx file:

src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { loadCatalog } from "./i18n";
import "./index.css";
await loadCatalog("en");
createRoot(document.getElementById("root")).render(
<StrictMode>
<I18nProvider i18n={i18n}>
<App />
</I18nProvider>
</StrictMode>
);

The i18n object contains the methods we’ll use to manage locales, message catalogs, and date formatting. The I18nProvider takes the i18n object as a prop, which allows the child components of <App /> to access the i18n instance.

Now for the actual translation, we’re going to translate the “Game Statistics” message at the top of the page. To make the string translatable, we’ll need to wrap it in a Trans macro:

src/App.jsx
<h1 className="text-3xl font-bold text-center text-blue-800 mb-8">Game Statistics</h1>
<h1 className="text-3xl font-bold text-center text-blue-800 mb-8"><Trans>Game Statistics</Trans></h1>

The <Trans> macro transforms the messages into components compatible with the ICU MessageFormat syntax, and it makes them messages available to the CLI tool for extraction. We’ll handle that in the next section.

Extracting Messages for Translation

The extract command will check our code for messages wrapped in <Trans> and place them in the message catalogs so we can translate them.

Open your terminal and execute this command:

Terminal window
npm run extract

You should see the following output:

Terminal window
Catalog statistics for src/locales/{locale}/messages:
┌─────────────┬─────────────┬─────────┐
Language Total count Missing
├─────────────┼─────────────┼─────────┤
en (source) │ 1 │ - │
fr 1 1
└─────────────┴─────────────┴─────────┘
(Use "npm run extract" to update catalogs with new messages.)
(Use "npm run compile" to compile catalogs for production. Alternatively, use bundler plugins: https://lingui.dev/ref/cli#compiling-catalogs-in-ci)

After extracting the messages to the catalog, the extract command displays a table showing how many messages each locale has and how many messages we haven’t translated. The table shows that there are two missing translations for the French locale and none for English. Since English is the source locale, there’s nothing to translate, so there’s a dash (-) in the “Missing” column.

Let’s add the missing translations to the message catalog. Open src/locales/fr/messages.po and you should see the message at the bottom of the file:

src/locales/fr/messages.po
msgid ""
msgstr ""
"POT-Creation-Date: 2025-01-27 16:43+0200\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"X-Generator: @lingui/cli\n"
"Language: fr\n"
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: \n"
"Last-Translator: \n"
"Language-Team: \n"
"Plural-Forms: \n"
#: src/App.jsx:46
msgid "Game Statistics"
msgstr ""

You should see the line numbers of the message we extracted, and the message id of the content wrapped <Trans>. We’ll add the translation to the msgstr field:

#: src/App.jsx:46
msgid "Game Statistics"
msgstr "Statistiques de jeu"

Next, we’ll have to compile the message catalogs to runtime catalogs. These runtime catalogs are minified .js files that allow us to use the messages in the app.

Open your CLI and run this command:

Terminal window
npm run compile

You should now see a messages.js file in each locale’s folder. Let’s change the locale to French and see the translation in action.

src/main.jsx
i18n.activate("fr");

First French Translations

Setting up a message for translation in Lingui may seem like a lot, but it’s actually a 4-step process:

  • Wrap the message you want to translate in a <Trans> macro.
  • Extract the message to a translation file.
  • Translate the message.
  • Compile the translated message to a runtime catalog.

Let’s translate other messages by wrapping them with the <Trans> macro (“High Score”, “Levels”, “Last played”, “Total play time”) and run the extract command:

src/locales/en/messages.po
#: src/App.jsx:46
msgid "Game Statistics"
msgstr "Game Statistics"
#: src/App.jsx:67
msgid "High Score"
msgstr "High Score"
#. placeholder {0}: formatLastPlayed(player.lastPlayed)
#: src/App.jsx:84
msgid "Last played {0}"
msgstr "Last played {0}"
#: src/App.jsx:74
msgid "Levels"
msgstr "Levels"
#. placeholder {0}: formatPlayTime(player.totalPlayTime)
#: src/App.jsx:88
msgid "Total play time: {0}"
msgstr "Total play time: {0}"

Lingui allows us to wrap variables and expressions in <Trans> so that we can easily use them in our translations. Also, as you may have noticed, Lingui automatically extracts the placeholders and comments in the messages. This makes it easier for translators to understand the context of the messages they’re translating.

There are a few strings that are not part of the JSX code, such as “Master” and “Expert” messages, so we can’t just wrap them as we did before. We should use the Lazy Translations approach to handle these strings.

Lazy translation allows you to defer translation of a message until it’s rendered, giving you flexibility in how and where you define messages in your code.

Let’s import the msg macro from @lingui/core/macro and use it to translate the “Master” and “Expert” messages:

import { msg } from "@lingui/core/macro";
// ...
const { i18n } = useLingui();
const players = [
{
id: "1",
username: "ProGamer123",
avatarUrl: "",
highScore: 2500,
levelsPlayed: 15,
lastPlayed: new Date(),
totalPlayTime: 180,
rank: msg`Master`
},
{
id: "2",
username: "QuizChampion",
avatarUrl: "",
highScore: 1800,
levelsPlayed: 8,
lastPlayed: new Date(Date.now() - 86400000),
totalPlayTime: 120,
rank: msg`Expert`
}
];

And in the JSX code:

<p className="text-sm text-blue-600">{player.rank}</p>
<p className="text-sm text-blue-600">{i18n._(player.rank)}</p>

Now let’s run the extract command again, add the French translations to the messages catalog (src/locales/fr/messages.po), compile them and see the results in the application:

src/locales/fr/messages.po
#: src/App.jsx:29
msgid "Expert"
msgstr "Expert"
#: src/App.jsx:49
msgid "Game Statistics"
msgstr "Statistiques de jeu"
#: src/App.jsx:70
msgid "High Score"
msgstr "Meilleur score"
#. placeholder {0}: formatLastPlayed(player.lastPlayed)
#: src/App.jsx:87
msgid "Last played {0}"
msgstr "Dernière partie jouée {0}"
#: src/App.jsx:77
msgid "Levels"
msgstr "Niveaux"
#: src/App.jsx:19
msgid "Master"
msgstr "Maître"
#. placeholder {0}: formatPlayTime(player.totalPlayTime)
#: src/App.jsx:91
msgid "Total play time: {0}"
msgstr "Temps de jeu total : {0}"

All strings translated

And that’s it! We’ve successfully translated all the messages in our application. The process is straightforward, and Lingui’s syntax makes it easy to work with.

Working with Plurals

Pluralization is a common issue in i18n. Different languages have different rules for plural forms, so it’s important to handle them correctly. Lingui provides a <Plural> macro to help with this.

Let’s add a new field to the player object in App.jsx to store the number of games played:

const players = [
{
// ...
gamesPlayed: 1
},
{
// ...
gamesPlayed: 10
}
];

And in the JSX code, we’ll display the number of games played:

<div className="text-sm text-gray-600 mt-1">{player.gamesPlayed} games played</div>

Image with plurals highlighted

Looking at the cards in the app, we can see “1 games played” for the first player and “10 games played” for the second player. They appear this way because we’re not using any logic to handle plural forms. Of course, we could use conditional logic to solve this problem, but it won’t work well across different locales. Let’s see how Lingui handles this issue.

Let’s wrap the gamesPlayed message in a <Plural> macro:

<div className="text-sm text-gray-600 mt-1">
{player.gamesPlayed} games played
<Plural value={player.gamesPlayed} one="# game played" other="# games played" />
</div>

The <Plural> macro takes a value and plural forms as props so it can render the correct plurals based on the value and locale. # is a placeholder for gamesPlayed, so the code above will render “1 game played” when gamesPlayed equals 1. Numbers greater than 1 will take the form of the other prop, so when gamesPlayed equals 10, <Plural> will render “10 games played”.

The <Plural> component also accepts exact forms so you can render specific messages for any number you want. For example, the _0 prop above will make <Plural> render “No games played” when gamesPlayed equals 0.

Back to our code: run extract, then open the French message catalog:

player.gamesPlayed
#: src/App.jsx:96
msgid "{0, plural, one {# game played} other {# games played}}"
msgstr "{0, plural, one {# game played} other {# games played}}"

The code here is similar to what we’ve seen so far. Since we’re dealing with plurals, we need to fill in the translation for each plural form. Copy the contents of msgid to msgstr and replace the English messages with the French translations:

player.gamesPlayed
#: src/App.jsx:96
msgid "{0, plural, one {# game played} other {# games played}}"
msgstr "{0, plural, =0 {Aucune partie jouée} one {# parties jouées} other {# parties jouées}}"

Compile the messages and the plurals should be working fine for both locales.

Formatting Dates and Numbers

Dates and numbers are formatted differently in different languages, but we don’t have to do this manually. The heavy lifting is done by the Intl object, we’ll just use the i18n.date() function.

The i18n object can be accessed with the useLingui hook:

src/Inbox.js
import { useLingui, Trans } from "@lingui/react/macro";
export default function Inbox() {
const { i18n } = useLingui();
return (
<div>
<footer>
<Trans>Last login on {i18n.date(lastLogin)}.</Trans>
</footer>
</div>
);
}

This will format the date using the conventional format for the active language. To format numbers, use the i18n.number() function.

The i18n.date() object takes two arguments: the message to be translated, and options for formatting. i18n.date() uses the Intl object under the hood, so the options parameter uses the same values that can be passed to the Intl.DateTimeFormat() method. You can check out all the date formatting options on MDN.

The example above is a generic code that shows how to format dates with Lingui. Our sample project doesn’t have any such messages. It only has the Intl.RelativeTimeFormat format for the “Last played” message. Let’s take a look:

src/main.jsx
const formatLastPlayed = (date) => {
return new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" }).format(
Math.ceil((date.getTime() - Date.now()) / 86400000),
"day"
);
};

The Intl.RelativeTimeFormat object formats the difference between two dates as a relative time. The numeric option can be set to auto, always, or never. The format() method takes two arguments: the difference between the two dates and the unit of time. It also takes the i18n.locale as the first argument to format the relative time according to the active locale.

Creating a Locale Switcher

After translating messages in your application, the last step is to give users the ability to switch languages. We’ll also make this locale switcher load messages dynamically using our loadCatalog helper function.

We only use one locale at a time, so there’s no need to load more than one when our application starts.

Let’s create a simple locale switcher component in the src/components/ directory:

src/components/LocaleSwitcher.jsx
import { loadCatalog } from "../i18n";
export const LocaleSwitcher = () => {
return (
<div className="flex space-x-2">
<button
className="text-blue-500 cursor-pointer hover:text-blue-900"
onClick={() => {
loadCatalog("en");
}}
>
English
</button>
<button
className="text-blue-500 cursor-pointer hover:text-blue-900"
onClick={() => {
loadCatalog("fr");
}}
>
Français
</button>
</div>
);
};

Then import the LocaleSwitcher component into src/App.jsx and add it to the bottom of the App component. Now you can switch locales and the application will dynamically load the messages:

Sample app with locale switcher

It depends on your project’s requirements how you want to implement the locale switcher, and also how you’d store the user’s locale preference. You could use a cookie, local storage, or a state management library like Redux or Recoil. Lingui provides the @lingui/detect-locale package to detect the user’s locale using various detection strategies.

Conclusion

We’ve come to the end of this tutorial. We’ve seen how Lingui’s syntax makes it easy to localize your application. The CLI commands give developers a simple workflow for extracting messages and tracking translations.

If you’d like to learn more about Lingui, here are some resources you may find helpful:

You can also check out Lingui’s official repository on GitHub.

Zayyad Muhammad Sani

Zayyad Muhammad Sani

Share this post: