How to Localize a React App With Lingui: A Practical Guide

· 13 min read · by Arvid Andersson
React Lingui i18n gettext Localization
How to Localize a React App With Lingui: A Practical Guide

Most React i18n libraries have you maintain JSON files with translation keys. You name every key, keep the files in sync, and hope nobody forgets to add the Swedish version of "dashboard.settings.notifications.email_frequency". It works, but there's a lot of bookkeeping and there are options.

Lingui is another .po-file based way. You write translatable strings directly in your JSX using macros like <Trans>Welcome home</Trans>, and then run a command that extract them into .po files. The source text becomes the key. No key naming. The runtime is around 5KB gzipped, which is smaller than most alternatives.

If you've worked with Django's makemessages or any GNU gettext project, the .po workflow should feel familiar. I've worked with .po-based i18n in Django for a while, and Lingui is a cool React/JSX implementation of the same model. The important thing is that the source of truth lives in your components, not in a translation file you have to keep in sync.

This guide covers Lingui v5 with a Vite + React setup, from installation to handling plurals and context, to automating translations so every language gets the same quality and consistency on every change.

How Lingui Works

Lingui has four main parts:

  1. Macros — you wrap translatable strings with <Trans>, t, or <Plural> in your JSX
  2. Extractionlingui extract scans your source code and generates .po files with every translatable string
  3. Translation — you (or a service) fill in the msgstr fields in the .po files for each target language
  4. Compilationlingui compile turns .po files into optimized JavaScript catalogs for runtime

What makes this different from react-i18next or next-intl, there are no JSON key files to maintain manually. The source text is the key. The macros are resolved at compile time (zero runtime overhead), and lingui extract handles the bookkeeping, adding new strings and flagging stale ones.

Setting It Up From Scratch

1. Install Dependencies

For a Vite + React project:

npm install @lingui/core @lingui/react
npm install --save-dev @lingui/cli @lingui/macro @lingui/vite-plugin @vitejs/plugin-react babel-plugin-macros

If you're on Next.js, the setup is a bit different since Next.js uses SWC instead of Babel. Replace @lingui/vite-plugin with @lingui/swc-plugin and configure it in next.config.js. The Lingui Next.js guide walks through the specifics.

2. Configure Lingui

Create lingui.config.js in your project root:

/** @type {import('@lingui/conf').LinguiConfig} */
export default {
  locales: ["en", "sv", "de"],
  sourceLocale: "en",
  catalogs: [
    {
      path: "src/locales/{locale}/messages",
      include: ["src"]
    }
  ],
  format: "po"
};

The catalogs config tells Lingui where to write .po files and which source directories to scan. The {locale} placeholder creates one file per language.

3. Configure Vite

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { lingui } from "@lingui/vite-plugin";

export default defineConfig({
  plugins: [
    react({
      babel: {
        plugins: ["@lingui/babel-plugin-lingui-macro"]
      }
    }),
    lingui()
  ]
});

4. Set Up the I18n Provider

// src/i18n.js
import { i18n } from "@lingui/core";

export async function loadCatalog(locale) {
  const { messages } = await import(`./locales/${locale}/messages.js`);
  i18n.loadAndActivate({ locale, messages });
}

// Load the default locale
loadCatalog("en");

export { i18n };
// src/main.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { I18nProvider } from "@lingui/react";
import { i18n } from "./i18n.js";
import { App } from "./App.jsx";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <I18nProvider i18n={i18n}>
      <App />
    </I18nProvider>
  </StrictMode>
);

Writing Translatable Components

Basic Strings

import { Trans } from "@lingui/react/macro";

function Hero() {
  return (
    <h1><Trans>Welcome to our app</Trans></h1>
  );
}

The text Welcome to our app becomes both the source string and the key in the .po file. No separate key to name or maintain.

Template Literals (Outside JSX)

For strings in attributes, variables, or anywhere outside JSX elements you do:

import { useLingui } from "@lingui/react/macro";

function SearchInput() {
  const { t } = useLingui();

  return (
    <input
      type="search"
      placeholder={t`Search products...`}
      aria-label={t`Search`}
    />
  );
}

Plurals

Lingui uses ICU MessageFormat for plurals:

import { Plural } from "@lingui/react/macro";

function CartSummary({ count }) {
  return (
    <p>
      <Plural
        value={count}
        one="# item in your cart"
        other="# items in your cart"
      />
    </p>
  );
}

This generates a .po entry with the ICU string:

msgid "{count, plural, one {# item in your cart} other {# items in your cart}}"
msgstr "{count, plural, one {# vara i din kundvagn} other {# varor i din kundvagn}}"

Languages with more plural forms (like Polish with 3, or Arabic with 6) just need the additional categories filled in.

Context

When the same English string has different meanings you just add a context prop to say which is which:

<Trans context="button">Save</Trans>    {/* Save as in "save the document" */}
<Trans context="discount">Save</Trans>  {/* Save as in "save money" */}

This produces two separate .po entries with the msgctxt field:

msgctxt "button"
msgid "Save"
msgstr "Spara"

msgctxt "discount"
msgid "Save"
msgstr "Spara pengar"

Comments for Translators

You can attach commects to help translators understand the context:

<Trans comment="Button in the header navigation, keep short">
  Sign in
</Trans>

This becomes a #. comment in the .po file:

#. Button in the header navigation, keep short
#: src/components/Header.jsx:7
msgid "Sign in"
msgstr ""

Explicit IDs

Lingui defaults to using the source text as the message ID, which is usually what you want. But you can set explicit IDs for keys that need to be stable across rewrites:

<Trans id="footer.copyright">
  © 2026 Acme Inc. All rights reserved.
</Trans>

This gives you a stable key (footer.copyright) even if you change the English text later. Lingui marks these entries with a #. js-lingui-explicit-id comment in the .po file.

Formatting Dates and Numbers

Lingui's @lingui/core provides locale-aware formatting for dates and numbers through the i18n object:

import { useLingui } from "@lingui/react";

function OrderSummary({ total, orderDate }) {
  const { i18n } = useLingui();

  return (
    <div>
      <p>Total: {i18n.number(total, { style: "currency", currency: "USD" })}</p>
      <p>Ordered: {i18n.date(orderDate, { dateStyle: "long" })}</p>
    </div>
  );
}

This uses the browser's Intl API under the hood, so the formatting adapts to whatever locale is active. i18n.number(1234.5) renders as 1,234.5 in English and 1 234,5 in Swedish. No translation file entries needed for these, the locale does the work.

Building a Language Switcher

The loadCatalog function from the provider setup handles the actual locale change. A simple switcher component could looks somethign like this:

import { useLingui } from "@lingui/react";
import { loadCatalog } from "../i18n.js";

const LOCALES = { en: "English", sv: "Svenska", de: "Deutsch" };

function LocaleSwitcher() {
  const { i18n } = useLingui();

  return (
    <select
      value={i18n.locale}
      onChange={(e) => loadCatalog(e.target.value)}
    >
      {Object.entries(LOCALES).map(([code, label]) => (
        <option key={code} value={code}>{label}</option>
      ))}
    </select>
  );
}

The loadCatalog function dynamically imports the catalog for the selected locale, so only the active language's translations are loaded. In a production app you'd likely persist the choice to localStorage or a cookie.

Extracting and Managing Translations

Running Extract

npx lingui extract

This scans your source files and updates .po files for each locale:

src/locales/
├── en/
│   └── messages.po    ← source strings with English values
├── sv/
│   └── messages.po    ← needs translation
└── de/
    └── messages.po    ← needs translation

What a Lingui .po File Looks Like

After extraction, the source locale file looks like this:

msgid ""
msgstr ""
"X-Generator: @lingui/cli\n"
"Language: en\n"

#. Button in the header navigation, keep short
#: src/components/Header.jsx:7
msgid "Sign in"
msgstr "Sign in"

#: src/App.jsx:14
msgid "Welcome to our app"
msgstr "Welcome to our app"

#: src/App.jsx:25
msgid "{count, plural, one {# item in your cart} other {# items in your cart}}"
msgstr "{count, plural, one {# item in your cart} other {# items in your cart}}"

A few things to note:

  • The #: lines are source references showing where each string appears in your code
  • The #. lines are your comments from <Trans comment="...">
  • ICU plurals stay as a single entry (Lingui doesn't split them into gettext's msgid_plural format)
  • The source locale has msgstr pre-filled with the English text

Handling Stale Translations

When you change a source string, lingui extract flags the old translation as fuzzy:

#, fuzzy
#: src/components/Header.jsx:7
msgid "Log in"
msgstr "Logga in"

The #, fuzzy flag basically means this translation was carried over from a similar string and needs review.

When you remove a string from your code entirely, lingui extract marks it as obsolete with #~, which is excluded from compilation.

Compiling for Production

npx lingui compile

This generates the JavaScript files that your app loads at runtime. Run this in your CI build pipeline to prepare translations for the build.

Common Gotchas

Don't Interpolate Outside the Macro

// Wrong: the interpolated value won't be extractable
<Trans>{`Hello ${name}`}</Trans>

// Right: use Lingui's interpolation
<Trans>Hello {name}</Trans>

Don't Split Sentences Across Components

// Wrong: translators can't reorder words across elements
<p><Trans>Click</Trans> <a><Trans>here</Trans></a> <Trans>to continue</Trans></p>

// Right: keep the full sentence together
<p><Trans>Click <a>here</a> to continue</Trans></p>

Lingui handles the inner <a> tag by indexing it (<0>here</0> in the .po file), so translators can reorder the link naturally.

Run Extract in CI

If you forget to run lingui extract before pushing, the .po files won't contain the new strings. Add it to your CI pipeline so extraction always happens before translation. See the automation section below for more info on how to make this work.

TypeScript Support

Lingui v5 has full TypeScript support. The macros transform at compile time, so there's no runtime overhead. If you're using TypeScript, the same setup works, just make sure your tsconfig.json includes the Lingui types:

{
  "compilerOptions": {
    "types": ["@lingui/macro"]
  }
}

Automating Your Translation Workflow

Getting the Lingui setup working is the first half. The second half is keeping translations in sync as your app evolves and your team works on it. A developer adds a new <Trans> component on Tuesday, and by Friday someone notices the Swedish version of the page has an untranslated string.

This is the part that tends to fall through the cracks after a while, especially on small teams. A few ways to handle it:

  • Human translators deliver quality but can introduce delays and probably cost. New features can wait days for translations, and someone still has to manage the handoff
  • Build your own LLM pipeline gives you full control, but you're maintaining prompts, quality checks, context windows, and probably still need a review workflow
  • AI-powered tools like Localhero.ai integrate into your development workflow directly, translating new strings on every pull request while respecting your glossary and style guide

This is the problem I'm building Localhero.ai to solve: making translations part of the development flow, not a separate step that someone has to remember.

Using Localhero.ai With Lingui

Localhero.ai detects Lingui projects. The CLI checks for lingui.config.js and configures everything on setup. Just run:

npx @localheroai/cli init

It sets up the project according to Lingui's conventions.

From there, push, translate, and pull work as expected. The CLI preserves all .po metadata through the round-trip, including #. comments, #: source references, msgctxt, and ICU plural forms.

GitHub Actions

To automate this on every pull request, add a GitHub Actions workflow. When you run localhero init, you'll get instructions for setting this up. The key point for Lingui projects is that the workflow needs to run lingui extract before the Localhero action, so new strings from the PR are picked up:

name: Translate

on:
  pull_request:
    paths:
      - "src/**/*.{ts,tsx,js,jsx}"
      - "src/locales/**"
      - "localhero.json"
  workflow_dispatch:

jobs:
  translate:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v5
        with:
          ref: ${{ github.head_ref }}
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: "20"

      - run: npm ci
      - run: npx lingui extract

      - uses: localheroai/localhero-action@v1
        with:
          api-key: ${{ secrets.LOCALHERO_API_KEY }}

Notice the paths trigger includes your source files (src/**/*.{ts,tsx,js,jsx}), not just the .po files. This ensures the workflow runs when you add new translatable strings to your components, not just when you change the translation files.

When to Choose Lingui

In my experience, Lingui is a good fit when:

  • You like the idea of haveing the source text in your components instead of separate JSON keys
  • You maybe have worked with gettext before and like the .po workflow.
  • You need ICU MessageFormat for plurals and complex messages
  • You work with professional translators who use gettext-compatible tools

Here's a quick comparison:

Lingui react-i18next next-intl
Source of truth Source text in JSX JSON key files JSON key files
File format .po (gettext) JSON JSON (ICU)
Key management Automatic extraction Manual Manual
Runtime size ~5KB gzipped ~20KB gzipped ~15KB gzipped
Server Components Yes (v5+) Partial Native

If you prefer JSON key-value files and a more explicit key-naming convention, react-i18next or next-intl might be a better match. I wrote a separate guide on localizing React apps with react-i18next and next-intl if that's more your style. There's no wrong answer here, just different trade-offs.

FAQ

How does Lingui compare to react-i18next? The main difference is where the source of truth lives. With react-i18next, you maintain JSON files with keys you name yourself. With Lingui, the source text lives in your components and .po files are generated automatically. Lingui has less boilerplate but requires a build step (the macro compilation). react-i18next is more flexible about loading strategies and has a larger ecosystem of plugins.

Can I use Lingui with Next.js App Router? Yes. Lingui has an SWC plugin (@lingui/swc-plugin) that works with Next.js. The setup is slightly different from Vite since you configure it in next.config.js instead of vite.config.js. The Lingui Next.js tutorial covers the specifics.

What about message IDs? Are they stable? By default, Lingui uses the source text as the message ID. If you change Save changes to Save all changes, it's treated as a new string. The old translation gets marked as fuzzy (carried over for review). If you need stable IDs that survive rewrites, use explicit IDs with <Trans id="...">.

Do I need gettext installed on my system? No. Unlike Django, which shells out to GNU gettext for extraction, Lingui has its own JavaScript-based extractor and compiler. You don't need brew install gettext or anything like that.

Can I use JSON instead of .po files? Yes, Lingui supports JSON as an output format. Set format: "minimal" in your lingui.config.js. But .po is the default and has a lot of tooling support, including professional translation tools, diff-friendly formatting, and built-in support for comments and fuzzy markers.

How big is Lingui's bundle size? The @lingui/core runtime is around 5KB gzipped. The macros (<Trans>, <Plural>, t) are compiled away at build time, so they add zero bytes to your bundle. Translation catalogs are loaded on demand via dynamic imports, so only the active locale's strings are in memory.

Does Lingui support React Server Components? Yes. Lingui v5 added support for React Server Components. You can use the t macro in server components directly. For client components you use the same <Trans> and useLingui APIs as before. The Lingui RSC tutorial has the full setup for Next.js App Router.

Summary

The Lingui setup takes about 15 minutes. After that, the day-to-day workflow is straightforward: write components with macros, run lingui extract, get the .po files translated, and run lingui compile. Automate the extract-and-translate step in CI and your team can ship features with consistent, on-brand translations across every language.

If you're interested in CI/CD integration, I wrote a detailed guide on automating i18n with GitHub Actions that covers several approaches.

And if automating on-brand translations for your projects sounds interesting, just ping us.


Cover photo by Andrew Ridley on Unsplash.

Ready to ship without translation delays?

No credit card required. Need help migrating? Just reach out.