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.
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:
npm install --save-dev @lingui/cli @lingui/corenpm install --save @lingui/react
Also, since we’re using Vite, we need to install @lingui/vite-plugin
:
npm install --save-dev @lingui/vite-plugin
Add the plugin to the Vite configuration:
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:
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
:
{ "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
:
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:
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:
<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:
npm run extract
You should see the following output:
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:
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:46msgid "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:46msgid "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:
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.
i18n.activate("fr");
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/App.jsx:46msgid "Game Statistics"msgstr "Game Statistics"
#: src/App.jsx:67msgid "High Score"msgstr "High Score"
#. placeholder {0}: formatLastPlayed(player.lastPlayed)#: src/App.jsx:84msgid "Last played {0}"msgstr "Last played {0}"
#: src/App.jsx:74msgid "Levels"msgstr "Levels"
#. placeholder {0}: formatPlayTime(player.totalPlayTime)#: src/App.jsx:88msgid "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/App.jsx:29msgid "Expert"msgstr "Expert"
#: src/App.jsx:49msgid "Game Statistics"msgstr "Statistiques de jeu"
#: src/App.jsx:70msgid "High Score"msgstr "Meilleur score"
#. placeholder {0}: formatLastPlayed(player.lastPlayed)#: src/App.jsx:87msgid "Last played {0}"msgstr "Dernière partie jouée {0}"
#: src/App.jsx:77msgid "Levels"msgstr "Niveaux"
#: src/App.jsx:19msgid "Master"msgstr "Maître"
#. placeholder {0}: formatPlayTime(player.totalPlayTime)#: src/App.jsx:91msgid "Total play time: {0}"msgstr "Temps de jeu total : {0}"
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>
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:
#: src/App.jsx:96msgid "{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:
#: src/App.jsx:96msgid "{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:
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:
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:
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:
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:
- React Apps Internationalization
- Lingui with React Server Components
- Macros Reference
- Core API Reference
- React API Reference
- Usage examples
You can also check out Lingui’s official repository on GitHub.