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

Supported

locale files

src/locales

file format

nested

01

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
src/locales/en.json
{
  "cart": {
    "summary": {
      "title": "Your cart",
      "items": "{{count}} items",
      "checkout": "Checkout"
    }
  }
}
02

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.

src/cart/CartSummary.tsx
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;
03

Back 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
04

Placeholders

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.

the source string
"items": "{{count}} items"
what fills it in
t('cart.summary.items', { count: 3 })
05

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 reviewed

XLIFF 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 format

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 files

react-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 setup

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?

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