Lingui translations in CI

lingui extract already gives you the catalog. lingui compile already turns it into runtime bundles. Between those two commands is a file full of empty msgstr entries that nothing in a Lingui project fills in.

The usual answer is to adopt a platform: hand the catalog to a TMS, sync it back down, keep the two copies in step. If you have translators who live in a web UI, that trade is worth making. Plenty of teams do not have those translators and would rather the catalog stayed in the repo where the rest of the source lives.

This page covers the other route: keep the catalog in git and fill in the missing translations as a step in CI.

The Lingui pipeline: lingui extract writes the catalog, a CI step fills in the empty entries, then lingui compile builds the runtime catalogs

For the basics of pointing Localhero.ai at a Lingui project, see the LinguiJS setup page. This page is about the CI loop and the plural handling underneath it.

The workflow file

Run lingui extract before the translation step so new messages are in the catalog by the time anything tries to translate them. That single ordering detail is what most broken setups get wrong.

.github/workflows/localhero-translate.yml

name: Localhero.ai - Automatic I18n translation

on:
  pull_request:
    paths:
      - "src/**/*.ts"
      - "src/**/*.tsx"
      - "src/**/*.js"
      - "src/**/*.jsx"
      - "src/locales/**"
      - "localhero.json"
  repository_dispatch:
    types: [localhero-sync]
  workflow_dispatch:

concurrency:
  group: translate-${{ github.event.client_payload.branch || github.head_ref || github.run_id }}
  cancel-in-progress: true

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

    steps:
      - uses: actions/checkout@v7
        with:
          ref: ${{ github.event.client_payload.branch || github.head_ref || github.ref_name }}
          fetch-depth: 0

      - uses: actions/setup-node@v4
        if: github.event_name == 'pull_request'
        with:
          node-version: '22'

      - name: Extract messages
        if: github.event_name == 'pull_request'
        run: |
          npm ci
          npx lingui extract

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

Three details worth pausing on.

The paths trigger lists your source files, not just the catalog. New messages arrive as <Trans> in a component; if the workflow only watches src/locales/** it will not run on the pull request that introduced them. Write one entry per extension: GitHub Actions path filters do not expand braces, so "src/**/*.{ts,tsx}" matches nothing at all.

node-version is '22' because Lingui 6 requires Node 22.19 or newer and ships ESM-only. On Node 20, lingui extract can exit without writing anything and without printing an error, which in CI reads as a green job that quietly translated nothing. Lingui's own test matrix runs 22 and 24.

Extraction runs on pull requests only. The repository_dispatch trigger fires when you sync reviewed translations back from Localhero.ai, and re-extracting during that run would fight the Action as it writes.

The full set of triggers and permissions is in the GitHub Actions guide.

Plurals, and why they need the attention

English has two plural forms. Polish has four. Russian has four. Japanese has one.

Lingui writes plurals as ICU MessageFormat inside a single message. A source string carries exactly the categories English needs.

#: src/CartSummary.tsx:14
msgid "{count, plural, one {# item in your cart} other {# items in your cart}}"
msgstr ""

The Polish translation of that message cannot be two branches with Polish words in them. It needs four, because Polish inflects differently for 1, for 2-4, then again for 5 and up. The two categories the source never had, few and many, have to be written from scratch by whoever fills in the msgstr.

An English message with the categories one and other expanding into a Polish message with one, few, many and other

Translations come back in the target language's own CLDR category set rather than a copy of the source language's:

#: src/CartSummary.tsx:14
msgid "{count, plural, one {# item in your cart} other {# items in your cart}}"
msgstr ""
"{count, plural, one {# produkt w Twoim koszyku} "
"few {# produkty w Twoim koszyku} "
"many {# produktów w Twoim koszyku} "
"other {# produktu w Twoim koszyku}}"

Arabic gets all six categories, Japanese collapses to other alone, exact matches like =0 {...} keep their meaning. selectordinal blocks follow the target language's ordinal categories, which are often a different set from its cardinal ones. The branches of a select block, gender being the common case, are left alone.

Stay on the default PO formatter

Lingui can write catalogs through po-gettext, which converts ICU plurals into native gettext msgid_plural and indexed msgstr[n] entries. For this workflow, do not.

Lingui's catalog formats documentation is direct about what that conversion costs:

Nested/multiple plurals in a message as shown in plural are not supported because they cannot be expressed with gettext plurals.

The select and selectOrdinal cannot be expressed with gettext plurals.

There is also a known gap where po-gettext emits two plural slots regardless of how many the target language needs (js-lingui#2345, open since October 2025). A Russian catalog comes out with two msgstr slots where Russian wants four.

The default formatter avoids all of it. The plural stays as ICU inside one msgstr, nested plurals and select and selectOrdinal survive intact, the number of categories is a property of the message rather than of a header the file has to agree with.

In Lingui 6 the formatter is imported rather than named as a string:

lingui.config.ts

import { defineConfig } from "@lingui/conf";
import { formatter } from "@lingui/format-po";

export default defineConfig({
  locales: ["en", "sv", "pl"],
  sourceLocale: "en",
  catalogs: [
    {
      path: "src/locales/{locale}/messages",
      include: ["src"]
    }
  ],
  format: formatter({ lineNumbers: true })
});

@lingui/format-po is a separate install. PO is still what you get when format is omitted entirely.

What survives the round-trip

A .po file carries more than message text. Losing the rest of it is a common way for an automated step to make a catalog worse. Through a translation run:

  • msgctxt is preserved; two entries with the same msgid and different contexts stay two entries
  • #: source references are kept
  • #. extracted comments, # translator comments and #, flags are kept
  • js-lingui-explicit-id keeps working; explicit IDs behave like generated ones
  • #~ obsolete entries stay in the file rather than being swept out
  • Generated files are checked with msgfmt before they land

Only the entries that changed are touched. The rest of the file, including its formatting, is left where it was, which keeps the pull request diff to the translations and nothing else.

What this does not require

No SDK, no runtime dependency added to your app. The catalog file is the integration.

No platform account that owns the catalog. The .po files in your repo stay the source of truth; there is no second copy to reconcile and nothing to re-import if you stop.

No lockfile or mapping artifact checked into the repo alongside the catalog.

Your lingui extract and lingui compile steps do not change. Nothing in the build has to know this ran.

Where a TMS could be the better tool

Localhero.ai is not a CI script with no interface. There is a web UI, a glossary with per-term translation strategies, translation memory that builds from your own past translations, automated quality checks, plus a review workflow where a reviewer comments and approves without touching git. Product managers, designers and bilingual colleagues review there; developers never leave the pull request.

The difference is which copy is authoritative. A TMS owns the catalog and your repository syncs to it. Here the .po files in git stay the source of truth and the review happens against the pull request that introduced the strings. Translation moves at the pace of the release rather than as a separate hand-off.

A traditional TMS earns its keep when translation is a standing function rather than part of shipping: full-time translators or an agency working through a vendor portal, purchase orders and per-word billing, translator marketplaces, certified workflows for regulated copy. Several platforms have real Lingui integrations; we compared them.

Further reading

Last updated

Ready to try it?

Get setup in a couple of minutes. No credit card required.