React · react-intl
Flat message ids, kept flat from push to pull.
react-intl looks every message up by a literal id, so its locale files are flat: one string per id, dots and all. Mergua keeps them that way in both directions — tell the sync script the folder and the word flat, and nothing about IntlProvider changes.
React + react-intl
/for/react-intl
locale files
src/lang
file format
flat
The files you already have
Mergua reads what is in your repo. Nothing is renamed, nothing moves, and no package is added to your project — the sync script is fetched with curl when the pipeline runs.
src/lang/
├── en.json ← source locale
├── de.json
└── fr.json{
"cart.summary.title": "Your cart",
"cart.summary.items": "{count, plural, one {# item} other {{count} items}}",
"cart.summary.checkout": "Checkout"
}How react-intl reads them
This is react-intl's own setup, not Mergua's — if you already have it, skip the panel. It is here so the page is a working path from empty project to translated screen rather than a page about a product.
import { FormattedMessage, useIntl } from 'react-intl';
export function CartSummary({ count }: { count: number }) {
const intl = useIntl();
return (
<section>
<h1>{intl.formatMessage({ id: 'cart.summary.title', defaultMessage: 'Your cart' })}</h1>
<p>
<FormattedMessage
id="cart.summary.items"
defaultMessage="{count, plural, one {# item} other {{count} items}}"
values={{ count }}
/>
</p>
</section>
);
}src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { IntlProvider } from 'react-intl';
import App from './App';
import en from './lang/en.json';
import de from './lang/de.json';
const messages = { en, de };
const locale = navigator.language.startsWith('de') ? 'de' : 'en';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<IntlProvider locale={locale} defaultLocale="en" messages={messages[locale]}>
<App />
</IntlProvider>
</StrictMode>,
);package.json
{
"scripts": {
"extract": "formatjs extract 'src/**/*.tsx' --ignore 'src/**/*.d.ts' --format simple --out-file src/lang/en.json"
}
}npm i react-intl
npm i -D @formatjs/cliBack into the build
Extract first, then sync: the extraction writes the ids your code declares into en.json, the push sends the new ones up, and the pull writes every finished language back as flat JSON in a commit on the branch the job is running on.
The job runs on the branch it is already on, so a feature branch syncs against the matching Mergua branch and main against main. That is the part no other translation tool does.
And you do not have to run it on a timer: a project can POST to a URL of yours when translations change or a branch is merged, so the job above starts on the change. The delivery is signed, it is filtered to the branch you name, and an import of four hundred keys arrives as one call.
# --path, because the CLI defaults to Angular's src/assets/i18n.
# --format flat, so every id comes back as the literal key it is.
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --path src/lang --format flat.gitlab-ci.yml
mergua-sync:
stage: .pre
image: node:22-alpine
before_script:
- apk add --no-cache curl jq git
- npm ci
script:
- npm run extract
- curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --path src/lang --format flat# If you would rather own the commit yourself, pull and
# commit in your own step:
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- pull --path src/lang --format flatPlaceholders
react-intl writes a variable as {count}. Mergua treats it as part of the string: if a translation comes back without it, the validator says so on the way out, and the AI translator is told to carry it through.
"cart.summary.items": "{count, plural, one {# item} other {{count} items}}"intl.formatMessage({ id: 'cart.summary.items' }, { count: 3 })Between the push and the pull
The job above is both ends of one loop, and what happens in between is people. A translation gets written, somebody else approves it, and sometimes a whole language leaves for an agency and comes back — none of which touches a file in your repo until the next pull.
Every id read by a second person
react-intl finds a message by its literal id, so the lang file is a flat list and a reviewer reads it as one: a key at a time, approved or rejected with the reason attached to the key itself. A review request mails everyone who could answer it, and a branch does not merge while anything on it is unreviewed — so what the pull writes into src/lang on main is what a second person has seen.
How a key gets from draft to reviewedYour ids travel to the agency as they are
An agency gets a language as one XLIFF 2.0 document and sends it back into the same keys, every dotted id unchanged, because an id is a key and not a path through anything. IntlProvider never reads the document and the sync script does not carry it: the lang file in src/lang stays the flat JSON your provider loads.
Why an agency asks for the formatWorth knowing on React
Extract with --format simple
Left to its default, formatjs extract writes every id as an object holding defaultMessage and description. Mergua reads strings, so that object is read as nesting and the description lands as a key of its own. --format simple writes the plain id to message pairs IntlProvider loads and Mergua reads.
Give every message an explicit id
Without an id, FormatJS derives one from a hash of the defaultMessage. Edit the English and the id changes with it — to Mergua that is a new key, and the translations filed under the old one no longer reach your app. Write the id yourself and a reworded source stays the same key.
Flat in, flat out
Mergua stores cart.summary.title as one key whichever way it arrived; --format decides what the pull writes. Without --format flat, the first pull rewrites every id as three levels of objects, and formatMessage finds none of them.
Plurals are ICU, and checked as ICU
{count, plural, …} is parsed for its structure. A translation that drops {count} from the case that used it is flagged before it ships; one that adds few and many for Polish is not, since those categories belong to the language.
How the placeholder check reads ICUA different stack?
Mergua writes plain locale files, so anything that loads one works. These stacks have a page of their own as well.
Run it on one branch.
Upload the src/lang/en.json you already have and see the whole loop on the free tier.
Create your account