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:
- Wrap your app in
I18nProvider - Mark messages for translation using macros (
Trans,t, etc.) - Extract messages:
lingui extract - Translate the catalogs
- Compile catalogs:
lingui compile - Load and activate locale in your app
Core Packages
Import from these packages:
Setup I18nProvider
Wrap your application with I18nProvider:
Translating UI Text
Choosing the Right Macro
Work through these questions in order:
- Does the message depend on a count? →
Plural(JSX) orplural(strings). Never wrap a count-dependent string in plainTrans— that bakes English plural rules into the message. - Is it JSX content? →
Trans - Is it a string inside a component (attribute, alert, function argument)? →
useLingui()+t`...` - Is it defined outside a component (module scope, constants, config)? →
msgdescriptor, resolved witht(descriptor)or_(descriptor)at render time - Is it in non-React code? →
tfrom@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 [blocked].
Use Trans for JSX Content
The Trans macro is the primary way to translate JSX:
When to use: For any translatable text in JSX elements.
Use useLingui for Non-JSX
For strings outside JSX (attributes, alerts, function calls):
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:
Use msg for Lazy Translations
When you need to define messages at module level or in arrays/objects:
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:
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:
The # placeholder is replaced with the actual value.
Exact Matches
Use _N syntax for exact number matches (takes precedence over plural forms):
With Variables and Components
Combine with Trans for complex messages:
Formatting Dates and Numbers
Use Intl directly:
Message IDs and Context
Explicit IDs
Provide a custom ID for stable message keys:
Context for Disambiguation
When the same text has different meanings, use context:
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:
Configuration
Basic lingui.config.js:
For detailed configuration patterns, see configuration.md [blocked].
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:
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:
- Prepend
lingui compile &&to the existingdev/buildscripts — never replace them, and don't rely on aprebuildhook: 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.pofiles — the translation source of truth:
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
compileNamespaceto how the app imports the catalog. If the code imports./locales/en/messagesas a.tsfile, setcompileNamespace: "ts"inlingui.configso a plainlingui compileregenerates exactly that artifact — no--typescriptflag anyone can forget. - Vite alternative: with
@lingui/vite-plugin, the app can dynamically import.pocatalogs 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 (Lingui 6.8+; on older versions use
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, and writes nothing. Mirror the flags the team extracts with (check sync --clean when the extract script uses --clean, same for --overwrite); otherwise the check compares against output the team never produces.
- Gate missing translations separately with
lingui check missing, which fails on translations still missing afterfallbackLocales(thecompile --strictrule, see configuration.md [blocked]). Run it in the release pipeline rather than on every PR when translations arrive asynchronously from a TMS — otherwise every feature PR fails until translators catch up.
Best Practices
Always Use Macros
Prefer macros over runtime components. Macros are compiled at build time, reducing bundle size:
Keep Messages Simple
Avoid complex expressions in messages - they'll be replaced with placeholders:
When extracting to a local variable isn't practical, name the placeholder inline with ph():
ph() also works inside Trans, Plural, and Select.
Use Trans for JSX, t for Strings
Choose the right tool:
Don't Use Macros at Module Level
Macros need component context - use msg instead:
Don't Wrap Non-UI Strings
Not every string is a message. Leave these unwrapped:
- CSS classes and
classNamevalues console.*/ logger output and developer-facing error codes- Import paths, URLs, API routes, query keys
- Object keys, enum values, ALL_CAPS constants,
data-testidvalues - 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:
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:
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
Loading Catalogs Dynamically
Memoization with useLingui
When using memoization, use the t function from the macro version:
Troubleshooting
If you encounter issues:
- Messages not extracted: Check
includepatterns inlingui.config.js - Translations not applied: Ensure catalogs are compiled with
lingui compile - Runtime errors: Verify
I18nProviderwraps your app - Type errors: Run
lingui compile --typescriptfor TypeScript projects
For detailed common mistakes and pitfalls, see common-mistakes.md [blocked].
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 [blocked]. Each of those has a version that compiles, ships, and is wrong; the recipes lead with the trap.


