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
json shape
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 })Worth 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.
Plurals 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?
It is JSON in and JSON out, so anything that reads a locale file works. These are the three with a page of their own.
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