React · i18next
Your locale files stay plain JSON in your repo.
i18next reads plain JSON and resolves keys through the dots — one file per locale, or a namespace file per feature once the app is big enough for that. Mergua reads and writes exactly those files, so nothing about your app changes: the only thing you tell the sync script is where the folder is.
React + i18next
/for/react-i18next
locale files
src/locales
file format
nested
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/locales/
├── en.json ← source locale
├── de.json
└── fr.json{
"cart": {
"summary": {
"title": "Your cart",
"items": "{{count}} items",
"checkout": "Checkout"
}
}
}How i18next reads them
This is i18next'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 { useTranslation } from 'react-i18next';
export function CartSummary({ count }: { count: number }) {
const { t } = useTranslation();
return (
<section>
<h1>{t('cart.summary.title')}</h1>
<p>{t('cart.summary.items', { count })}</p>
</section>
);
}src/i18n.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import de from './locales/de.json';
import fr from './locales/fr.json';
i18n.use(initReactI18next).init({
resources: {
en: { translation: en },
de: { translation: de },
fr: { translation: fr },
},
lng: 'en',
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
export default i18n;src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './i18n';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);src/i18n.ts
// A namespace is a second file for the same language. The
// http backend's default layout is already the one Mergua
// reads — the language folder first, the file name last:
//
// public/locales/en/common.json
// public/locales/en/checkout.json
import HttpBackend from 'i18next-http-backend';
i18n.use(HttpBackend).use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
ns: ['common', 'checkout'],
defaultNS: 'common',
backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' },
});
t('checkout:summary.title');npm i i18next react-i18nextBack into the build
Two flags and the loop is closed: keys you added in code go up, translations that came back land in src/locales as 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 stays nested, which is what i18next reads.
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --path src/locales.github/workflows/i18n.yml
name: Mergua sync
on: push
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync --path src/locales
env:
MERGUA_API_KEY: ${{ secrets.MERGUA_API_KEY }}
MERGUA_PROJECT_ID: ${{ vars.MERGUA_PROJECT_ID }}# react-intl compiles to flat message ids — {"cart.summary.title": "..."} —
# so point the CLI at the compiled folder and say so:
curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- sync \
--path src/lang --format flatPlaceholders
i18next 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.
"items": "{{count}} items"t('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.
One namespace at a time, by a second person
Reviewers approve or reject a key at a time, and a rejection carries its reason back to whoever wrote it, as a draft on their own list rather than a message they have to find. The editor filters by file and the file is the namespace, so checkout.json gets read through without common.json in the way — the same split t('checkout:summary.title') already reads by — and asking for the review mails the people who can answer it. Nothing unreviewed merges, so what the pull writes on main is what a second person has seen.
How a key gets from draft to reviewedXLIFF is how a language leaves
An agency does not want your locales folder, it wants XLIFF — so a language goes out as one XLIFF 2.0 document and returns as one, every namespace inside it a file element naming its own JSON, checkout.json, and filed back where it started. i18next never sees it and the CI script does not carry it, deliberately: the document is the handoff, and what src/locales holds is still the JSON your app loads.
Why an agency asks for the formatWorth knowing on React
A folder per locale is what Mergua reads
i18next-http-backend defaults to public/locales/en/translation.json — a folder per locale with a namespace file inside — and that is the layout a Mergua project holds. The file name is the namespace, so checkout.json becomes checkout and a plain en.json is read as the language rather than a file. Point the job at the folder above the locales, --path public/locales, and every namespace goes up and comes back in place.
Splitting a language across several filesreact-intl means --format flat
react-intl ids are literal strings with dots in them, not a path through nested objects. Pass --format flat so Mergua writes them back the same way; leave it at nested and the first pull turns every id into a three-level object your app cannot find.
The whole react-intl setupPlurals are keys, and Mergua keeps them
i18next writes items_one and items_other beside each other. They are ordinary keys, so they round-trip untouched — but a translator sees them as two entries, which is right: a language with six plural forms needs six.
escapeValue: false is for React, not for Mergua
React already escapes what it renders, so i18next should not escape it again. It has no bearing on what is stored — Mergua keeps the string as written either way.
A 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/locales/en.json you already have and see the whole loop on the free tier.
Create your account