---
name: lingui-best-practices
description: Implement internationalization with Lingui in React and JavaScript applications. Use when adding i18n, translating UI, working with Trans/useLingui/Plural, extracting messages, compiling catalogs, or when the user mentions Lingui, internationalization, i18n, translations, locales, message extraction, ICU MessageFormat, or working with .po files.
---
# Lingui Best Practices
Lingui is a powerful internationalization (i18n) framework for JavaScript. This skill covers best practices for implementing i18n in React and vanilla JavaScript applications.
## Quick Start Workflow
The standard Lingui workflow consists of these steps:
1. Wrap your app in `I18nProvider`
2. Mark messages for translation using macros (`Trans`, `t`, etc.)
3. Extract messages: `lingui extract`
4. Translate the catalogs
5. Compile catalogs: `lingui compile`
6. Load and activate locale in your app
## Core Packages
Import from these packages:
```jsx
// React macros (recommended)
import { Trans, Plural, Select, useLingui } from "@lingui/react/macro";
// Core macros for vanilla JS
import { t, msg, plural, select } from "@lingui/core/macro";
// Runtime (rarely used directly)
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
```
## Setup I18nProvider
Wrap your application with `I18nProvider`:
```jsx
import { I18nProvider } from "@lingui/react";
import { i18n } from "@lingui/core";
import { messages } from "./locales/en/messages";
i18n.load("en", messages);
i18n.activate("en");
function App() {
return (
{/* Your app */}
);
}
```
## Translating UI Text
### Choosing the Right Macro
Work through these questions in order:
1. **Does the message depend on a count?** → `Plural` (JSX) or `plural` (strings). Never wrap a count-dependent string in plain `Trans` — that bakes English plural rules into the message.
2. **Is it JSX content?** → `Trans`
3. **Is it a string inside a component** (attribute, alert, function argument)? → `useLingui()` + `` t`...` ``
4. **Is it defined outside a component** (module scope, constants, config)? → `msg` descriptor, resolved with `t(descriptor)` or `_(descriptor)` at render time
5. **Is it in non-React code?** → `t` from `@lingui/core/macro`
If the string needs a translator comment, take the **object** form of whichever macro the tree picks — `` t`…` `` and `` msg`…` `` have nowhere to attach one. Deciding that while you wrap costs nothing; converting a whole codebase afterwards does not. See the enhanced-message-context skill.
Some destinations expect a plain string and will not take a macro at all — a Zod message, a count inside an `aria-label`, a server function's return value, an `Intl` formatter. Those have their own recipes: [integration-recipes.md](references/integration-recipes.md).
### Use Trans for JSX Content
The `Trans` macro is the primary way to translate JSX:
```jsx
import { Trans } from "@lingui/react/macro";
// Simple text
Hello World
// With variables
Hello {userName}
// With components (rich text)
Read the documentation for more info.
// Extracted as: "Read the <0>documentation0> for more info."
```
**When to use**: For any translatable text in JSX elements.
### Use useLingui for Non-JSX
For strings outside JSX (attributes, alerts, function calls):
```jsx
import { useLingui } from "@lingui/react/macro";
function MyComponent() {
const { t } = useLingui();
const handleClick = () => {
alert(t`Action completed!`);
};
return (
);
}
```
**When to use**: Element attributes, alerts, function parameters, any non-JSX string.
The macro hook returns **`i18n` as well as `t`**, and both are bound to the React context — so one hook covers reading the active locale, formatting against it, and subscribing the component to locale changes. One import covers it — the runtime `useLingui` from `@lingui/react` is for code that has no macro transform:
```jsx
import { useLingui } from "@lingui/react/macro";
const { t, i18n } = useLingui();
i18n.locale; // "de-DE" — the active locale
new Intl.NumberFormat(i18n.locale) // format against it
```
### Use msg for Lazy Translations
When you need to define messages at module level or in arrays/objects:
```jsx
import { msg } from "@lingui/core/macro";
import { useLingui } from "@lingui/react";
// Module-level constants
const STATUSES = {
active: msg`Active`,
inactive: msg`Inactive`,
pending: msg`Pending`,
};
function StatusList() {
const { _ } = useLingui();
return Object.entries(STATUSES).map(([key, message]) => (
{_(message)}
));
}
```
**When to use**: Module-level constants, arrays of messages, conditional message selection.
#### Descriptors change the field's type
`msg` turns a `string` field into a `MessageDescriptor`, so TypeScript points at every consuming site — which is what makes this conversion safe to apply in bulk. Two things get through it:
**React keys keep compiling.** `key={item.label}` becomes an object key, which React stringifies to `[object Object]` — identical for every row, so reconciliation degrades and the only signal is a console warning. Key on an identifier, never on the copy:
```jsx
// ❌ compiles; every key is now identical
{NAV.map((item) =>
{t(item.label)}
)}
// ✅
{NAV.map((item) =>
{t(item.label)}
)}
```
**String methods become type errors with a tempting wrong fix.** `LABELS[k].toLowerCase()` fails to compile — correctly — but `t(LABELS[k]).toLowerCase()` is not the repair. Lower-casing a translation breaks languages that capitalise by rule (German nouns) and is a no-op in scripts without case. If a lower-case variant is really needed, it is a second message with its own comment.
## Pluralization
Use the `Plural` macro for quantity-dependent messages:
```jsx
import { Plural } from "@lingui/react/macro";
```
The `#` placeholder is replaced with the actual value.
### Exact Matches
Use `_N` syntax for exact number matches (takes precedence over plural forms):
```jsx
```
### With Variables and Components
Combine with `Trans` for complex messages:
```jsx
You have # messages, {userName}
}
/>
```
## Formatting Dates and Numbers
Use `Intl` directly:
```jsx
import { useLingui } from '@lingui/react/macro';
function MyComponent() {
const { i18n } = useLingui();
const lastLogin = new Date();
const dateFormatter = useMemo(() => new Intl.DateTimeFormat(i18n.locale), [i18n.locale]);
return Last login: {dateFormatter.format(lastLogin)};
}
```
## Message IDs and Context
### Explicit IDs
Provide a custom ID for stable message keys:
```jsx
Welcome to our app
```
### Context for Disambiguation
When the same text has different meanings, use `context`:
```jsx
rightright
```
These create separate catalog entries.
Use `context` only when the same text genuinely needs different translations — not as a namespacing scheme (`auth.login`, `settings.title`). Identical strings with identical meaning should share one catalog entry so they are translated once.
### Comments for Translators
Add context for translators:
```jsx
Hello World
```
## Configuration
Basic `lingui.config.js`:
```js
import { defineConfig } from "@lingui/cli";
export default defineConfig({
sourceLocale: "en",
locales: ["en", "es", "fr", "de"],
catalogs: [
{
path: "/src/locales/{locale}/messages",
include: ["src"],
exclude: ["**/node_modules/**"],
},
],
});
```
For detailed configuration patterns, see [configuration.md](references/configuration.md).
### Lingui 6 Notes
Lingui 6 (April 2026) is ESM-only and requires Node.js ≥ 22.19 (or ≥ 24). If the project can't meet that, pin all `@lingui/*` packages to `^5`.
The deprecated string form `format: "po"` and the `formatOptions` option were removed in v6. Omit `format` entirely (PO remains the default), or pass a formatter instance to configure it:
```js
import { defineConfig } from "@lingui/cli";
import { formatter } from "@lingui/format-po";
export default defineConfig({
// ...
format: formatter({ lineNumbers: false }),
});
```
`lineNumbers: false` keeps catalog diffs small — line-number comments change on almost every source edit.
## Catalog Hygiene
Wire extraction and compilation into the project so they can't be forgotten:
```json
{
"scripts": {
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile",
"dev": "lingui compile && vite",
"build": "lingui compile && vite build"
}
}
```
- **Prepend `lingui compile && ` to the existing `dev`/`build` scripts** — never replace them, and don't rely on a `prebuild` hook: pnpm ≥ 7 and Yarn Berry don't run pre/post hooks by default.
- **Gitignore compiled catalogs by extension, never by directory.** A directory rule like `src/locales/` also swallows the `.po` files — the translation source of truth:
```gitignore
# ✅ compiled artifacts only
src/locales/**/messages.ts
src/locales/**/messages.js
# ❌ never — silently untracks the .po sources too
# src/locales/
```
Verify with `git check-ignore`: the compiled file must match, its `.po` sibling must not. Ignoring compiled catalogs is only safe because `lingui compile` runs before every build — don't do one without the other.
- **Match `compileNamespace` to how the app imports the catalog.** If the code imports `./locales/en/messages` as a `.ts` file, set `compileNamespace: "ts"` in `lingui.config` so a plain `lingui compile` regenerates exactly that artifact — no `--typescript` flag anyone can forget.
- **Vite alternative**: with `@lingui/vite-plugin`, the app can dynamically import `.po` catalogs directly (`await import(\`./locales/${locale}/messages.po\`)`) — the plugin compiles on the fly, so there are no compiled catalog files to script around or gitignore.
- **Add a CI drift check** so catalog state is part of the PR contract:
```json
"i18n:check": "lingui compile && lingui extract --clean && git diff --exit-code -- src/locales"
```
This fails the build when someone adds or edits a message without re-running extraction.
## Best Practices
### Always Use Macros
Prefer macros over runtime components. Macros are compiled at build time, reducing bundle size:
```jsx
// ✅ Good - uses macro
import { Trans } from "@lingui/react/macro";
// ❌ Avoid - runtime only
import { Trans } from "@lingui/react";
```
### Keep Messages Simple
Avoid complex expressions in messages - they'll be replaced with placeholders:
```jsx
// ❌ Bad - loses context
Hello {user.name.toUpperCase()}
// Extracted as: "Hello {0}"
// ✅ Good - clear variable name
const userName = user.name.toUpperCase();
Hello {userName}
// Extracted as: "Hello {userName}"
```
When extracting to a local variable isn't practical, name the placeholder inline with `ph()`:
```jsx
import { ph } from "@lingui/core/macro";
// Extracted as: "Hello {name}" instead of "Hello {0}"
t`Hello ${ph({ name: getUserName() })}`;
```
`ph()` also works inside `Trans`, `Plural`, and `Select`.
### Use Trans for JSX, t for Strings
Choose the right tool:
```jsx
// ✅ For JSX content
Welcome
// ✅ For string values
const { t } = useLingui();
```
### Don't Use Macros at Module Level
Macros need component context - use `msg` instead:
```jsx
// ❌ Bad - won't work
import { t } from "@lingui/core/macro";
const LABELS = [t`Red`, t`Green`, t`Blue`];
// ✅ Good - use msg for lazy translation
import { msg } from "@lingui/core/macro";
const LABELS = [msg`Red`, msg`Green`, msg`Blue`];
```
### Don't Wrap Non-UI Strings
Not every string is a message. Leave these unwrapped:
- CSS classes and `className` values
- `console.*` / logger output and developer-facing error codes
- Import paths, URLs, API routes, query keys
- Object keys, enum values, ALL_CAPS constants, `data-testid` values
- Values that are compared against or persisted (statuses, slugs)
Locale-prefixing URLs is a routing concern, not a translation concern — don't wrap paths in macros.
### Use the ESLint Plugin
Install and configure `eslint-plugin-lingui` to catch common mistakes automatically:
```bash
npm install --save-dev eslint-plugin-lingui
```
```js
// eslint.config.js
import pluginLingui from "eslint-plugin-lingui";
export default [
pluginLingui.configs["flat/recommended"],
];
```
## Locale Metadata: Single-Source It
Define locale facts once in a shared module with no React or framework imports, so it's safe to use from config, middleware, tests, and components alike:
```ts
// src/i18n/locales.ts
export const locales = ["en", "es", "fr", "ar"] as const;
export type Locale = (typeof locales)[number];
export const sourceLocale: Locale = "en";
const RTL_LOCALES = new Set(["ar", "he", "fa", "ur"]);
export const getDirection = (locale: string): "ltr" | "rtl" =>
RTL_LOCALES.has(locale.split("-")[0]) ? "rtl" : "ltr";
// "Deutsch", not "German" — each language name rendered in its own language
export const localeDisplayName = (locale: string) =>
new Intl.DisplayNames([locale], { type: "language" }).of(locale) ?? locale;
// `null` is in the signature on purpose: detect() and headers.get() both return it
export function resolveLocale(candidate: string | null | undefined): Locale {
if (!candidate) return sourceLocale;
if ((locales as readonly string[]).includes(candidate)) return candidate as Locale;
const base = candidate.split("-")[0]; // es-MX → es
return (locales as readonly string[]).includes(base) ? (base as Locale) : sourceLocale;
}
```
Signs this went wrong: `getDirection` or `Intl.DisplayNames` defined in more than one file, hardcoded `dir="rtl"` conditionals scattered around, hand-maintained language-name maps.
Layout caveat: don't keep both `src/i18n.ts` and `src/i18n/` — the flat file shadows the directory's `index.ts` in module resolution, the build still passes, and the app is quietly wrong. Pick one layout.
## Common Patterns
### Dynamic Locale Switching
```jsx
import { i18n } from "@lingui/core";
async function changeLocale(locale) {
const { messages } = await import(`./locales/${locale}/messages`);
i18n.load(locale, messages);
i18n.activate(locale);
}
```
### Loading Catalogs Dynamically
```jsx
import { useEffect } from "react";
import { i18n } from "@lingui/core";
function loadCatalog(locale) {
return import(`./locales/${locale}/messages`);
}
function App() {
useEffect(() => {
loadCatalog("en").then(catalog => {
i18n.load("en", catalog.messages);
i18n.activate("en");
});
}, []);
return {/* ... */};
}
```
### Memoization with useLingui
When using memoization, use the `t` function from the macro version:
```jsx
import { useLingui } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
import { useMemo } from "react";
const welcomeMessage = msg`Welcome!`;
function MyComponent() {
const { t } = useLingui(); // Macro version - reference changes with locale
// ✅ Safe - t reference updates with locale
const message = useMemo(() => t(welcomeMessage), [t]);
return
{message}
;
}
```
## Troubleshooting
If you encounter issues:
1. **Messages not extracted**: Check `include` patterns in `lingui.config.js`
2. **Translations not applied**: Ensure catalogs are compiled with `lingui compile`
3. **Runtime errors**: Verify `I18nProvider` wraps your app
4. **Type errors**: Run `lingui compile --typescript` for TypeScript projects
For detailed common mistakes and pitfalls, see [common-mistakes.md](references/common-mistakes.md).
For the seams where Lingui meets a library that wants a plain string — validation schemas, plurals inside attributes, `i18n._()` with values, server-composed messages, `Intl` formatters — see [integration-recipes.md](references/integration-recipes.md). Each of those has a version that compiles, ships, and is wrong; the recipes lead with the trap.