All articles
9 min read

How to Translate JSON Files with AI (Without Breaking Your App)

A practical guide to translating JSON locale files with AI: preserving placeholders and nesting, handling plurals, reviewing output, and keeping keys in sync.

JSONAI translationi18n

Machine translation is good enough to be genuinely useful for locale files, and bad enough to break your UI if you feed it raw JSON and ship the result. The failures are rarely "the German is wrong" – they are mangled interpolation tokens, a plural form that reads like a robot in Polish, and a key that quietly kept its English value for four releases.

This guide walks through the parts that actually cause incidents: key structure, placeholder protection, plural rules, context, batching, the review gate, and keeping translations in sync when the source changes. There is a worked example translating a small en.json into German and French, and a CI setup at the end.

Flat vs nested keys

Two conventions dominate. Nested JSON mirrors your feature tree:

{
  "checkout": {
    "cart": {
      "empty": "Your cart is empty",
      "itemCount": "{count} items in cart"
    }
  }
}

Flat JSON stores the dotted path as a literal key:

{
  "checkout.cart.empty": "Your cart is empty",
  "checkout.cart.itemCount": "{count} items in cart"
}

Nested is easier for humans to scan and diff, and it is what react-i18next, vue-i18n and most JS frameworks expect by default. Flat is much easier for tooling: every key is a stable string identifier, there is no ambiguity about whether a dot is a separator or part of a key name, and diffs stay line-per-string instead of collapsing whole subtrees.

The practical answer: keep whatever your framework wants in the repo, but flatten before you send anything to a model. A flat list of key -> string pairs is far less likely to come back with restructured nesting, dropped intermediate objects, or an extra wrapper the model invented. Flatten, translate, unflatten. In Node that is about ten lines:

function flatten(obj: unknown, prefix = '', out: Record<string, string> = {}) {
  for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
    const path = prefix ? `${prefix}.${k}` : k;
    if (v && typeof v === 'object' && !Array.isArray(v)) flatten(v, path, out);
    else out[path] = String(v);
  }
  return out;
}

One caveat: if your keys can contain literal dots, flattening is lossy. Pick a separator you never use in key names, or normalise your keys first.

Placeholders and ICU interpolation

This is where naive machine translation does the most damage. Every i18n library has its own interpolation syntax, and none of it is natural language:

{
  "greeting": "Hello, {name}!",
  "greetingVue": "Hello, {{ name }}!",
  "greetingPo": "Hello, %(name)s!",
  "greetingIcu": "{gender, select, male {He} female {She} other {They}} replied"
}

A general-purpose translation model will happily translate the token itself – {name} becomes {nom} in French, {{ count }} picks up a stray space, an ICU select block gets its keywords localised. Your app then renders the literal string, or throws at parse time.

Three defences, in order of reliability:

  1. Extract before, restore after. Replace each placeholder with an opaque sentinel the model will not touch, translate, then substitute back. Sentinels like ⟦0⟧ or __PH0__ tend to survive intact. Keep a per-string map so restoration is exact.
  2. Instruct explicitly. Tell the model that anything between braces is code, must be copied byte-for-byte, and may be reordered but never translated, changed, added or removed. Reordering matters – German and Japanese often need the placeholder in a different position.
  3. Validate after. This is the non-negotiable one. Parse the placeholder set out of source and target and compare:
const tokens = (s: string) => (s.match(/\{[^}]*\}|%[sd]|%\([^)]+\)s/g) ?? []).sort();

function placeholdersMatch(src: string, tgt: string) {
  const a = tokens(src), b = tokens(tgt);
  return a.length === b.length && a.every((t, i) => t === b[i]);
}

Run that over every translated string in CI and fail the build on mismatch. It catches the class of bug that is otherwise found by a user in production.

Plurals are not one string

English lulls people into thinking plurals are a two-case problem: one item, N items. CLDR defines six possible plural categories – zero, one, two, few, many, other – and languages use different subsets. English uses two. Russian, Polish and Czech use more, and the rules depend on the last digit and last two digits of the number, not on "is it 1".

So this English source:

{ "cart.items": "{count, plural, one {# item} other {# items}} in cart" }

cannot be translated into Russian by translating two strings. The Russian target legitimately needs more forms:

{ "cart.items": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товара}} в корзине" }

Two rules follow. First, use ICU message format (or your library's plural mechanism) rather than string-concatenating a number in application code – once the count is glued on in JavaScript, no translator can fix it. Second, let the model add or drop plural branches per target language, and validate the result differently: instead of requiring identical structure, require that the target's branch set is exactly the set of CLDR categories valid for that locale. new Intl.PluralRules(locale).resolvedOptions().pluralCategories gives you that set at runtime, so the check is cheap and needs no plural-rules dependency.

The same applies to gender select blocks and to ordinals. If your source uses them, say so in the prompt; models handle ICU well when told it is ICU, and poorly when they think it is prose with punctuation problems.

Give the model context

A locale file is a pile of short strings with no surrounding text. "Post" is a verb on a button and a noun in a feed. "Free" is price or availability. Models guess, and guess consistently wrong across a whole file.

Context that helps:

  • The key name. nav.profile.edit tells the model this is navigation, not a sentence. Send keys alongside values – never values alone.
  • A description field. If your format supports metadata, use it. Even a five-word note ("button label, imperative") changes the output.
  • Character limits. German translations routinely run longer than the English source, especially for short UI labels where there is no room to absorb the extra characters. If a string sits in a fixed-width button, say max 18 characters and the model will pick a shorter synonym.
  • A glossary. Product names, feature names and terms of art must be identical everywhere. Pass a term list with "do not translate" and "always translate as" entries, and check compliance afterwards.
  • Tone and formality. German du vs Sie, French tu vs vous, Japanese politeness level. Decide once, put it in the prompt, keep it in your style guide.

If your screens are unusual, a screenshot alongside the batch genuinely helps multimodal models disambiguate – though it is more effort than most teams need for most files.

Batching and cost

Do not send one request per string: you lose cross-string consistency and pay the system prompt over and over. Do not send a 4000-key file in one request either – long outputs drift, and a single malformed character costs you the whole batch.

A reasonable shape: a few dozen strings per request, grouped by feature area so related strings share a request, with the glossary and style rules in the system prompt so they can be cached across batches. Ask for strict JSON output and validate the shape before accepting it. Retry a failed batch once at a lower temperature; if it fails again, split it.

Locale files are small compared with most other things you send a model, and the bill is usually minor next to the engineering time spent wiring the pipeline up – so optimise for correctness and for cheap re-translation, not for pennies per run. If you use a platform instead of calling a model yourself, the accounting differs: the unit you budget in becomes source characters rather than tokens.

The review gate

Ship raw machine translation to production and you will eventually publish something embarrassing in a language nobody on the team reads. The workflow that holds up is: AI produces a draft, a human approves it, only approved strings reach the build.

That means your storage needs a per-string status, not just a value. Translatize models this with per-label review statuses, so AI output lands as a draft and a reviewer promotes it, with every project member notified over WebSocket as labels change. Whatever tool you use, the important property is that "translated" and "trusted" are different states, and your export can filter on the second one.

Pair the human gate with automated checks that run first, so reviewers spend their attention on meaning rather than on catching broken braces: placeholder parity, plural category validity, glossary compliance, length limits, and "target is not byte-identical to source" for languages where that is suspicious.

Keeping keys in sync

The slow failure mode is drift. Someone edits the English copy for checkout.cta; the German value stays as the translation of the old English and is now subtly wrong. Nothing errors. Nothing is missing.

The fix is a digest. Store a hash of the source string next to each translation:

{
  "key": "checkout.cta",
  "source": "Complete purchase",
  "sourceDigest": "sha256:9f2c…",
  "targets": { "de": { "value": "Kauf abschließen", "digest": "sha256:9f2c…" } }
}

On every build, recompute the source hash. If it differs from the digest stored with a translation, that translation is stale – flag it, re-translate it as draft, and send it back through review. This single mechanism covers the three states you care about: missing (no target), stale (digest mismatch), and current (digest match).

Deletions matter too. Prune keys that no longer exist in the source, but do it deliberately – a key removed by mistake and then re-added loses all its translations if pruning is automatic and immediate.

Branching helps here more than people expect. If a feature branch changes twelve English strings, you want those twelve re-translated and reviewed in isolation and merged when the feature merges – not dumped into the main locale file where they compete with everything else in flight. Translatize supports exactly this: a branch per feature, translation in isolation, then a permission-guarded merge back to main with overwrite (a non-destructive union where the source branch wins conflicts), replace, keep-newer or manual conflict resolution. Who may do what is role-based per project: developers and above can create branches, only the creator or an admin can delete one, and only an admin or owner can merge into main.

Worked example: en.json to de and fr

Source file:

{
  "app": {
    "title": "Postbox",
    "nav": { "inbox": "Inbox", "settings": "Settings" }
  },
  "inbox": {
    "greeting": "Good morning, {name}",
    "unread": "{count, plural, one {# unread message} other {# unread messages}}",
    "empty": "Nothing here yet",
    "cta": "Compose"
  }
}

Flatten it, then send a request whose system prompt carries the rules and whose user message carries the batch:

You are translating UI strings for a web email client.

Rules:
- Return JSON only: an object mapping each input key to the translated string.
- Text inside curly braces is code. Copy it exactly. You may move it within
  the sentence; never translate, rename, add or remove it.
- Strings using ICU plural syntax must use the plural categories valid for the
  target locale, which may differ in number from the source.
- Do not translate the product name "Postbox".
- Target locale: de-DE. Register: formal (Sie).
- "app.nav.*" and "inbox.cta" are UI controls: max 14 characters, imperative.

Expected German output, flat:

{
  "app.title": "Postbox",
  "app.nav.inbox": "Posteingang",
  "app.nav.settings": "Einstellungen",
  "inbox.greeting": "Guten Morgen, {name}",
  "inbox.unread": "{count, plural, one {# ungelesene Nachricht} other {# ungelesene Nachrichten}}",
  "inbox.empty": "Noch nichts vorhanden",
  "inbox.cta": "Verfassen"
}

And French:

{
  "app.title": "Postbox",
  "app.nav.inbox": "Boîte de réception",
  "app.nav.settings": "Paramètres",
  "inbox.greeting": "Bonjour, {name}",
  "inbox.unread": "{count, plural, one {# message non lu} other {# messages non lus}}",
  "inbox.empty": "Rien pour le moment",
  "inbox.cta": "Rédiger"
}

Note what the validators would catch here. app.nav.inbox in French is 18 characters and busts the stated 14-character limit – a real constraint violation a reviewer must resolve, either by shortening the label or widening the control. German Einstellungen is 13 and squeaks under it. The placeholder {name} survived in both. The plural branches stayed at one/other, which is correct for German and French; a Polish target would need one/few/many/other, and the parity check must expect that rather than demanding the source's two branches.

Automating it in CI

Translation belongs in the same pipeline as the code it ships with. The Translatize CLI is configured once with npx @translatize/cli init, which writes a translatize.config.json recording the project id, a file pattern such as locales/{lang}.json, and whether your on-disk format is nested or flat JSON. After that, three commands do the work: push uploads new and changed local keys (it never deletes remote keys), pull writes one file per language back to disk, and status compares local files against the branch and is the one you use as a gate.

A workable GitHub Actions job:

name: i18n
on:
  pull_request:
    paths: ['locales/en.json']

jobs:
  translate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20' }
      - name: Push changed source strings
        run: npx @translatize/cli push
        env:
          TRANSLATIZE_API_TOKEN: ${{ secrets.TRANSLATIZE_API_TOKEN }}
      - name: Fail the PR if a language is incomplete
        run: npx @translatize/cli status --fail-on-missing
        env:
          TRANSLATIZE_API_TOKEN: ${{ secrets.TRANSLATIZE_API_TOKEN }}
      - name: Validate placeholders and plurals
        run: node scripts/validate-locales.mjs

The push step uploads changed source keys; the status step turns a missing translation into a red PR check; the local validation script runs the parity and plural checks so a broken string fails the PR rather than a release. Pull translated values back on a schedule or on merge, once a human has approved them – that ordering is what keeps the review gate meaningful. Integration tokens are prefixed mcni_ and bound to a single project and branch, which is why running this per-branch is safe: a token issued for a feature branch cannot write anywhere else. There are ready-made GitHub Action and GitLab CI templates if you would rather not hand-write the job, and the CLI, SDK and MCP server cover the same operations if you want to script it yourself. Webhooks for label and branch events are the other half of this: they let your own systems react when a translation is approved or a branch is merged, instead of polling.

If you are working in a specific framework, the mechanics differ a little: see translating Next.js apps, React i18n, or YAML locale files for format-specific notes. For a wider view of how the pieces fit together, the AI localization workflow covers the process end to end.

Where Translatize fits

Everything above assumes something holds the strings between the model and the repo. JSON is the format Translatize handles most directly: import and export are symmetric, nested or flat, with every language in one file – the same shape your en.json already has. The other eight formats (YAML, CSV, XLIFF 1.2, gettext PO, Flutter ARB, .NET RESX, Android XML, iOS .strings) move in and out the same way, so a JSON project that later ships a mobile client does not need a second pipeline.

AI auto-translation fills only the missing values, and it fills them as drafts – the review gate this article argues for, expressed in storage rather than in convention. A reviewer moves a label from draft to review to approved or rejected, and the description, screenshot and charLimit fields travel with the key, so the reviewer sees the same constraint the model was given. Auto-translation is on the Professional and Agency plans, metered by characters per month.

Wrapping up

The hard part of AI translation is not the translation. It is the surrounding discipline: flatten before you send, protect placeholders and verify them mechanically, treat plurals as per-language structures rather than strings, give the model enough context to disambiguate, gate everything behind human approval, and use source digests so stale translations announce themselves instead of hiding.

Get those six things right and machine translation becomes a genuine accelerator – drafts in minutes, reviewers spending their time on nuance, and no more releases where the French checkout button says {count}.

Keep reading