An open multilingual codex glowing on a desk with a gray cat resting nearby

Lingui: Translate Your App Without Hiding the Words

The Gray Cat
The Gray Cat
0 views

If you have ever localized a JavaScript app, you know the quiet tax of key-based internationalization. You write a button, then you invent a key like dashboard.header.welcomeTitle, then you copy the actual English text into a JSON file somewhere far away, and from that moment on your code and your copy live in separate universes. Six months later nobody remembers what dashboard.header.welcomeTitle actually says, and the only way to find out is to go spelunking through a translation file.

Lingui takes the opposite approach. You write the real, natural-language string directly in your JSX or your JavaScript, and Lingui's tooling statically extracts those strings into message catalogs for translators. Your default-language copy stays where it belongs: in the code, where you can read it. Under the hood it speaks ICU MessageFormat, the battle-tested standard for plurals, gender selects, and interpolation, so even gnarly grammatical cases stay manageable. It works across vanilla JavaScript, React, React Native, Next.js, and beyond, and the core runtime weighs in at roughly 2 kb.

Why Inline Beats Keys

The central promise of Lingui is that messages are not divorced from your source. When you write <Trans>Welcome back</Trans>, the text "Welcome back" is both what renders and what gets extracted. There is no parallel namespace of cryptic identifiers to maintain, no drift between the key and the copy.

Because extraction happens at compile time through macros, Lingui can do things runtime-only libraries cannot. Unused messages get stripped from your production bundle. Default text needs no runtime lookup at all. And translators receive proper PO files (the Gettext format) complete with source comments and context, which is dramatically friendlier than a flat wall of JSON keys. A few features worth knowing up front:

  • Compile-time extraction via Babel, SWC, or Vite plugins, so default copy carries zero lookup overhead.
  • Rich-text messages where React components live inside a translated string and become indexed placeholders like <0>...</0> in the catalog.
  • ICU MessageFormat for plurals, selects, and ordinals in a single string rather than a sprawl of separate keys.
  • Flexible message IDs that can be auto-generated hashes of your source text, or explicit identifiers when you want them.
  • A real toolchain with a CLI for extraction and compilation, plus plugins for Vite, SWC, and ESLint.

Getting Lingui Into Your Project

Install the runtime packages plus the CLI tooling. The core package is framework-agnostic; add @lingui/react when you are working in React.

# npm
npm install --save @lingui/core @lingui/react
npm install --save-dev @lingui/cli

# yarn
yarn add @lingui/core @lingui/react
yarn add --dev @lingui/cli

You will also wire the Lingui macro plugin into your Babel, SWC, or Vite config so the t and Trans macros get transformed at build time, and add a couple of scripts:

{
  "scripts": {
    "extract": "lingui extract",
    "compile": "lingui compile"
  }
}

A minimal lingui.config.js tells the CLI which locales you support and where catalogs should live:

export default {
  locales: ["en", "cs", "fr"],
  sourceLocale: "en",
  catalogs: [
    {
      path: "<rootDir>/src/locales/{locale}/messages",
      include: ["<rootDir>/src"],
    },
  ],
  format: "po",
};

Speaking to the Core Engine

The heart of Lingui is the i18n object from @lingui/core, and it needs no React at all. You load compiled catalogs, activate a locale, and translate. The recommended pattern is lazy loading: only fetch the catalog for the locale you actually need.

import { i18n } from "@lingui/core";

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

await activateLocale("en");

i18n.t("Hello");
i18n.t("My name is {name}", { name: "Tom" });

The same object handles locale-aware formatting, so you do not need to reach for a separate date or currency library:

i18n.date(new Date(), { dateStyle: "long" });
i18n.number(1234.5, { style: "currency", currency: "USD" });

If you need an isolated instance, say one per request in a server context, setupI18n() creates a fresh instance rather than mutating the shared global. That single detail makes Lingui comfortable in SSR and React Server Component setups where a global locale would be a race condition waiting to happen.

Wrapping React in a Provider

In a React app you wire everything together with I18nProvider. Load and activate a catalog, then hand the configured i18n instance to the provider near the root of your tree.

import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { messages } from "./locales/en/messages";

i18n.load({ en: messages });
i18n.activate("en");

export const App = () => (
  <I18nProvider i18n={i18n}>
    <Dashboard />
  </I18nProvider>
);

From there, the Trans macro handles any JSX content, including nested components and links. This is where inline messages really shine, because the entire phrase, markup and all, stays readable as one unit:

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

function Inbox() {
  return (
    <>
      <h1><Trans>Message Inbox</Trans></h1>
      <p>
        <Trans>
          See all <a href="/unread">unread messages</a> or{" "}
          <a onClick={markAllRead}>mark them</a> as read.
        </Trans>
      </p>
    </>
  );
}

When that gets extracted, the links collapse into indexed placeholders so a translator sees the sentence structure without needing to touch your HTML. For strings that live outside JSX, such as an alt attribute or an alert, reach for the useLingui hook and its t template tag:

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

function ImageCard() {
  const { t } = useLingui();
  return (
    <img
      alt={t`A photograph of the night sky`}
      onClick={() => alert(t`Marked as read.`)}
    />
  );
}

The Extract-and-Compile Rhythm

Now for the part that makes Lingui tick: the workflow. You never hand-write catalogs. Instead you write messages inline, then let two CLI commands do the bookkeeping. First, lingui extract scans your source, pulls every message into per-locale .po catalogs, and crucially merges with what is already there so existing translations survive.

#. Docs link on the website
msgid "msg.docs"
msgstr "Read the <0>documentation</0> for more info."

Translators (or a translation-management platform like Crowdin or Lokalise consuming those PO files) fill in the empty msgstr entries. Then lingui compile turns the catalogs into minified JavaScript modules optimized for runtime loading. A good habit is to treat compiled output as a throwaway build artifact: gitignore it and run lingui compile in CI. The extract step accepts useful flags like --clean to drop obsolete messages and --watch for local development, while compile offers --strict to fail the build on missing translations and --typescript for typed output.

Plurals Without the Key Explosion

Pluralization is where key-based libraries tend to fall apart, demanding separate keys for _one and _other and leaving the actual grammar scattered. Lingui keeps it in one ICU string via the Plural macro:

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

function MessageCount({ count }: { count: number }) {
  return (
    <Plural
      value={count}
      _0="No messages"
      one="# message"
      other="# messages"
    />
  );
}

That compiles down to a single ICU message: {count, plural, =0 {No messages} one {# message} other {# messages}}. The # is replaced by the value, and the _0 form matches an exact count. Lingui ships companion select and selectOrdinal macros for gender-based copy and ordinal numbers ("1st", "2nd", "3rd"), all following the same compact, translator-readable pattern. Because the entire rule lives in one message, a translator working in a language with four plural forms can express all four without you touching the code.

How It Stacks Up

The obvious comparison is i18next, the long-reigning incumbent. It is key-based and resolves text from JSON at runtime, which gives it a huge, mature ecosystem of backends, language detectors, and plugins. Lingui's pitch is different: text-first source, compile-time extraction that strips unused messages for smaller bundles, and ICU plurals in a single string instead of a constellation of keys. If you value a sprawling plugin ecosystem and runtime flexibility, i18next has the edge; if you value clean code and a tight footprint, Lingui does.

Against react-intl (FormatJS), the two share ICU MessageFormat, but react-intl leans on explicit message descriptors and <FormattedMessage id=... />, which tends toward more boilerplate. Lingui's macros auto-generate IDs and let you write the default text inline, so you write less and read more.

It is worth being honest about the trade-offs. Lingui depends on a build-step transform, and a misconfigured macro plugin is the most common onboarding snag. The two-step extract-and-compile pipeline is more moving parts than dropping a JSON file into i18next, and the macro magic can feel implicit until you have debugged why a string did not get picked up once or twice. The plugin ecosystem is also smaller than i18next's. Note too that in version 6 the old standalone @lingui/macro package is superseded by importing macros directly from @lingui/core/macro and @lingui/react/macro, so some older tutorials will look slightly different from current practice.

The Takeaway

Lingui makes a clear bet: your source code should read like the product, not like a lookup table. By keeping natural-language copy inline and pushing all the catalog bookkeeping into a compile-time toolchain, it delivers readable code, ICU-grade plural handling, rich-text messages, and a roughly 2 kb runtime. The price is a build step and a two-command workflow you have to wire into CI. For teams shipping to many locales who want their default copy to live in the code and their translators to receive proper context-rich catalogs, that is a trade worth making. Active at version 6.3.0 with well over a million weekly downloads on the core package, Lingui is a mature, modern answer to a problem most apps eventually face.