# How to Translate JSON Files for i18n (Without Breaking Them)

> Four ways to turn en.json into every language you need, with copy-paste scripts for a translation API and an LLM, placeholder protection, validation, plural forms and incremental runs.
- **Author**: Mohit Sapat
- **Published**: 2026-10-25
- **URL**: https://www.babelize.co/blogs/translate-json-files-i18n

---

<p class="lead">
    You have an <code>en.json</code> with a few hundred strings and you need <code>de.json</code>, <code>fr.json</code> and <code>ja.json</code> by Friday. Pasting the file into a translator looks like the fast way. It's also the fastest way to ship translated keys, broken placeholders and a file that won't parse. This guide covers four ways to translate a JSON locale file, with code you can copy, and the checks that keep each one from breaking your app.
</p>

<h2>Quick Answer</h2>

<p>
    Translate the <strong>values</strong>, never the keys. Protect placeholders (<code>{name}</code>, <code>{{count}}</code>), ICU syntax and tags before the text reaches a translator, give each target language its own plural forms, then validate the result: same keys as the source, same placeholders in every value, valid JSON. Save the file only if every check passes, and on later runs translate only the strings that changed.
</p>

<p>
    If you're unsure what goes inside these files (nested keys, <code>{name}</code> vs <code>{{name}}</code>, naming), start with <a href="/blogs/json-translation-files-explained/">JSON translation files explained</a>. This post is about getting from one language to many.
</p>

<h2>What Can Go Wrong</h2>

<p>Here is a small source file:</p>

<pre><code>{
  "auth": {
    "login": "Log in",
    "welcome": "Welcome back, {name}"
  },
  "cart": {
    "items_one": "{{count}} item",
    "items_other": "{{count}} items",
    "terms": "Read the &lt;link&gt;terms&lt;/link&gt; before you pay."
  }
}</code></pre>

<p>And here is what a careless translation into German produces:</p>

<pre><code>{
  "authentifizierung": {
    "anmelden": "Anmelden",
    "welcome": "Willkommen zurück, {Name}"
  },
  "cart": {
    "items_one": "{{Anzahl}} Artikel",
    "items_other": "{{count}} Artikel",
    "terms": "Lesen Sie die &lt;Link&gt;Bedingungen&lt;/Link&gt;, bevor Sie bezahlen.",
  }
}</code></pre>

<p>Every line here is a real failure mode:</p>

<ul>
    <li><strong>Translated keys:</strong> <code>auth.login</code> no longer exists in German, so the app falls back to English or shows the raw key.</li>
    <li><strong>Changed placeholders:</strong> <code>{Name}</code> and <code>{{Anzahl}}</code> are new variables nobody passes. Users see the literal braces, or nothing.</li>
    <li><strong>Renamed tags:</strong> your component maps <code>&lt;link&gt;</code>, not <code>&lt;Link&gt;</code>, so the link disappears.</li>
    <li><strong>A trailing comma:</strong> the whole file fails to parse, and every German string is gone at once.</li>
</ul>

<p>
    None of these show up in a code review of the English UI. They show up in production, in a language your team may not read. So every method below follows the same rule: protect, translate, validate, then write.
</p>

<h2>Method 1: Human Translators via a Spreadsheet</h2>

<p>
    Translators and reviewers rarely want raw JSON. The easy fix is to flatten the file into rows of key and value, send a spreadsheet, and rebuild the JSON from the answers:
</p>

<pre><code>// flatten.mjs
export function flatten(obj, prefix = "", out = {}) {
  for (const [k, v] of Object.entries(obj)) {
    const key = prefix ? `${prefix}.${k}` : k;
    if (v &amp;&amp; typeof v === "object" &amp;&amp; !Array.isArray(v)) flatten(v, key, out);
    else out[key] = v;
  }
  return out;
}

export function unflatten(flat) {
  const out = {};
  for (const [key, v] of Object.entries(flat)) {
    const parts = key.split(".");
    let node = out;
    for (const part of parts.slice(0, -1)) node = node[part] ??= {};
    node[parts.at(-1)] = v;
  }
  return out;
}</code></pre>

<p>
    <code>flatten</code> turns <code>{"auth": {"login": "Log in"}}</code> into <code>{"auth.login": "Log in"}</code>, which maps to one spreadsheet row. Lock the key column so nobody edits it, add a column for context ("button on the checkout page"), and run the validation script from later in this post on whatever comes back. This approach breaks if your keys themselves contain dots, so pick a different separator if they do.
</p>

<p>
    <strong>Good for:</strong> small apps, legal or marketing copy, and final review of machine output. <strong>Watch out for:</strong> it's slow, and spreadsheets go stale the moment a developer adds a key.
</p>

<h2>Method 2: A Machine Translation API, With Placeholders Protected</h2>

<p>
    A translation API such as DeepL or Google Cloud Translation is fast and cheap. DeepL's free API plan, for example, covers 500,000 characters a month. The catch is that these APIs translate everything you send, including <code>{name}</code>. The fix is to wrap anything that must survive in a tag the API is told to ignore.
</p>

<p>
    With DeepL, that's <code>tag_handling: "xml"</code> plus <code>ignore_tags</code>. Because the text is now treated as XML, the rest of it has to be escaped too:
</p>

<pre><code>// protect.mjs
// {{count}}, {name}, &lt;link&gt; / &lt;/link&gt; / &lt;br/&gt;, %s and %1$s
export const TOKEN = /\{\{[^}]+\}\}|\{[\w.]+\}|&lt;\/?[a-zA-Z][\w-]*\s*\/?&gt;|%(?:\d+\$)?[sd]/g;
// ICU messages: {count, plural, ...} / select / selectordinal
export const ICU = /\{\s*\w+\s*,\s*(plural|select|selectordinal)\b/;

const esc = (s) =&gt; s.replace(/&amp;/g, "&amp;amp;").replace(/&lt;/g, "&amp;lt;").replace(/&gt;/g, "&amp;gt;");
const unesc = (s) =&gt;
  s.replace(/&amp;lt;/g, "&lt;").replace(/&amp;gt;/g, "&gt;").replace(/&amp;quot;/g, '"')
   .replace(/&amp;apos;/g, "'").replace(/&amp;amp;/g, "&amp;");

export function protect(text) {
  let out = "";
  let last = 0;
  for (const m of text.matchAll(TOKEN)) {
    out += esc(text.slice(last, m.index)) + `&lt;x&gt;${esc(m[0])}&lt;/x&gt;`;
    last = m.index + m[0].length;
  }
  return out + esc(text.slice(last));
}

export const unprotect = (text) =&gt; unesc(text.replace(/&lt;\/?x&gt;/g, ""));</code></pre>

<p>
    <code>protect("Welcome back, {name}")</code> becomes <code>Welcome back, &lt;x&gt;{name}&lt;/x&gt;</code>, and DeepL leaves everything inside <code>&lt;x&gt;</code> alone. Then batch the strings, since one request takes up to 50 texts:
</p>

<pre><code>// translate-deepl.mjs
import fs from "node:fs";
import { flatten, unflatten } from "./flatten.mjs";
import { protect, unprotect, ICU } from "./protect.mjs";

async function deepl(texts, targetLang) {
  const res = await fetch("https://api-free.deepl.com/v2/translate", {
    method: "POST",
    headers: {
      Authorization: `DeepL-Auth-Key ${process.env.DEEPL_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      text: texts,
      source_lang: "EN",
      target_lang: targetLang,
      tag_handling: "xml",
      ignore_tags: ["x"],
    }),
  });
  if (!res.ok) throw new Error(`DeepL ${res.status}: ${await res.text()}`);
  const { translations } = await res.json();
  return translations.map((t) =&gt; t.text);
}

const source = flatten(JSON.parse(fs.readFileSync("locales/en.json", "utf8")));
const strings = Object.entries(source).filter(([, v]) =&gt; typeof v === "string");
const icu = strings.filter(([, v]) =&gt; ICU.test(v));
const plain = strings.filter(([, v]) =&gt; !ICU.test(v));

const target = {};
for (let i = 0; i &lt; plain.length; i += 50) {
  const batch = plain.slice(i, i + 50);
  const out = await deepl(batch.map(([, v]) =&gt; protect(v)), "DE");
  batch.forEach(([key], j) =&gt; (target[key] = unprotect(out[j])));
}

console.log(`Translated ${plain.length} strings. Skipped ${icu.length} ICU messages:`);
for (const [key] of icu) console.log("  " + key);
// Validate before writing (see below), then:
// fs.writeFileSync("locales/de.json", JSON.stringify(unflatten(target), null, 2) + "\n");</code></pre>

<p>Two things this script deliberately doesn't do:</p>

<ul>
    <li><strong>It skips ICU messages.</strong> In <code>{count, plural, one {# item} other {# items}}</code>, the words inside the branches must be translated but the structure must not. A regex can't separate the two safely. Send those to a human, or to an LLM with the validation below.</li>
    <li><strong>It only translates strings.</strong> Numbers, booleans and arrays are left out of the target file, so your i18n library falls back to the source value for them.</li>
</ul>

<p>
    Expect to retry: rate limits and timeouts happen on any translation API. See <a href="/blogs/translation-api-failure-handling/">why translation APIs break and how to handle it</a> for retries, <code>Retry-After</code> and fallbacks.
</p>

<p>
    <strong>Good for:</strong> plain UI strings at volume. <strong>Watch out for:</strong> no product context ("Open" the button and "Open" the ticket status come back the same), and plurals need separate handling.
</p>

<h2>Method 3: An LLM, With Its Output Treated as Untrusted</h2>

<p>
    An LLM handles what the API can't: it can follow instructions about placeholders and ICU, and you can give it context. Send a JSON object of keys and values plus a short brief, and ask for the same object back:
</p>

<pre><code>Translate the values of this JSON object from English to German.

Rules:
- Return a JSON object with exactly the same keys. Do not translate keys.
- Keep {name}, {{count}}, &lt;link&gt;...&lt;/link&gt;, %s and similar tokens exactly as written.
- In ICU messages ({count, plural, ...}), translate only the text inside the
  branches and use German plural categories.
- Product: a project management app. Tone: informal, use "du".
- Keep these terms in English: Babelize, Workspace.

{"auth.login": "Log in", "auth.welcome": "Welcome back, {name}", ...}</code></pre>

<p>
    Use your provider's JSON mode or structured output so the response always parses, and send 20 to 50 keys per request so one bad response doesn't cost you the whole file. Glossary terms and a line about the product do more for quality than any choice of model.
</p>

<p>
    Then assume the model made mistakes, because sometimes it will: a dropped placeholder, an English value handed back, or a key it invented. Which brings us to the step that matters for every method.
</p>

<p>
    <strong>Good for:</strong> UI copy with context, ICU messages, tone. <strong>Watch out for:</strong> confident mistakes, so never write its output without the checks below.
</p>

<h2>Validate Before You Write the File</h2>

<p>
    Whatever produced the translation, a person, an API or a model, run the same checks on it. This one compares the target to the source key by key:
</p>

<pre><code>// validate.mjs
import { parse } from "@formatjs/icu-messageformat-parser";
import { TOKEN, ICU } from "./protect.mjs";

const tokens = (s) =&gt; (s.match(TOKEN) ?? []).sort().join("|");

export function validate(source, target) {
  const problems = [];
  const warnings = [];
  for (const [key, src] of Object.entries(source)) {
    if (typeof src !== "string") continue;
    const t = target[key];
    if (t === undefined) { problems.push(`${key}: missing`); continue; }
    if (typeof t !== "string" || t.trim() === "") problems.push(`${key}: empty`);
    else if (tokens(src) !== tokens(t)) problems.push(`${key}: placeholders or tags changed`);
    else if (t === src &amp;&amp; /[a-z]{4}/i.test(src)) warnings.push(`${key}: identical to source`);
    if (ICU.test(src)) {
      try { parse(t); } catch { problems.push(`${key}: invalid ICU message`); }
    }
  }
  for (const key of Object.keys(target)) {
    if (!(key in source)) problems.push(`${key}: not in the source file`);
  }
  return { problems, warnings };
}</code></pre>

<p>
    "Identical to source" is a warning rather than an error: brand names, codes and words like "OK" legitimately stay the same. Everything else should stop the write. Leave failed keys out of the file instead of filling them with English, so the fallback is visible and the next run can retry them:
</p>

<pre><code>const { problems, warnings } = validate(source, target);
if (warnings.length) console.warn(warnings.join("\n"));
const failed = new Set(problems.map((p) =&gt; p.split(":")[0]));
const clean = Object.fromEntries(Object.entries(target).filter(([k]) =&gt; !failed.has(k)));

const json = JSON.stringify(unflatten(clean), null, 2) + "\n";
fs.writeFileSync("locales/de.json.tmp", json);
fs.renameSync("locales/de.json.tmp", "locales/de.json"); // never a half-written file

if (problems.length) {
  console.error(problems.join("\n"));
  process.exitCode = 1;
}</code></pre>

<p>
    Writing to a temporary file and renaming it means a crash halfway through can't leave a truncated <code>de.json</code> behind. Run the same <code>validate()</code> in CI on every locale file, so a hand edit with a missing brace fails the build instead of the release.
</p>

<h2>Plurals: Don't Translate the File One to One</h2>

<p>
    This is the mistake that passes every check above. English has two plural forms. Many languages have more:
</p>

<pre><code>new Intl.PluralRules("en").resolvedOptions().pluralCategories; // one, other
new Intl.PluralRules("pl").resolvedOptions().pluralCategories; // one, few, many, other
new Intl.PluralRules("ar").resolvedOptions().pluralCategories; // zero, one, two, few, many, other
new Intl.PluralRules("ja").resolvedOptions().pluralCategories; // other</code></pre>

<p>
    With i18next, plural forms are separate keys: <code>items_one</code> and <code>items_other</code> in English. A Polish file translated one to one has only those two, so Polish users never see the forms for "2 items" or "5 items". The Polish file needs its own keys:
</p>

<pre><code>{
  "items_one": "{{count}} przedmiot",
  "items_few": "{{count}} przedmioty",
  "items_many": "{{count}} przedmiotów",
  "items_other": "{{count}} przedmiotu"
}</code></pre>

<p>
    So when the target language has categories the source doesn't, generate the extra keys from <code>Intl.PluralRules</code> and ask for each form. With ICU messages it's the same idea inside one string: the Polish message needs <code>one</code>, <code>few</code>, <code>many</code> and <code>other</code> branches. For Japanese, the opposite: one <code>_other</code> key covers every count. The <a href="/blogs/icu-messageformat-plurals-guide/">ICU MessageFormat plurals guide</a> covers the syntax in detail.
</p>

<h2>Only Translate What Changed</h2>

<p>
    Translating the whole file on every release wastes money and quietly replaces translations someone already reviewed. Instead, record a hash of each source string when you translate it, and on the next run only send keys whose hash changed or that the target file doesn't have yet:
</p>

<pre><code>import crypto from "node:crypto";

const hash = (s) =&gt; crypto.createHash("sha256").update(s).digest("hex").slice(0, 16);
const lockPath = "locales/.translate-lock.json";
const lock = fs.existsSync(lockPath) ? JSON.parse(fs.readFileSync(lockPath, "utf8")) : {};
lock.de ??= {};

const existing = flatten(JSON.parse(fs.readFileSync("locales/de.json", "utf8")));
const todo = Object.entries(source).filter(
  ([key, value]) =&gt; typeof value === "string" &amp;&amp; (lock.de[key] !== hash(value) || !(key in existing)),
);

// translate `todo`, merge into `existing`, validate, write, then:
for (const [key, value] of todo) if (!failed.has(key)) lock.de[key] = hash(value);
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + "\n");</code></pre>

<p>
    Commit the lock file next to your locale files. A teammate who fixes a typo in the English text then triggers a retranslation of exactly that one string, and everyone else's run costs nothing.
</p>

<h2>Which Method Should You Use?</h2>

<table>
    <thead>
        <tr><th>Method</th><th>Best for</th><th>Cost</th><th>Main risk</th></tr>
    </thead>
    <tbody>
        <tr><td>Pasting into a translator or online JSON tool</td><td>A one-off look at a tiny file</td><td>Free</td><td>Translated keys, broken placeholders, your strings on a third-party site</td></tr>
        <tr><td>Human translators via spreadsheet</td><td>Legal, marketing, final review</td><td>Highest</td><td>Slow, goes stale quickly</td></tr>
        <tr><td>Translation API script</td><td>Plain UI strings at volume</td><td>Low</td><td>No context, ICU and plurals need extra work</td></tr>
        <tr><td>LLM script with validation</td><td>UI copy with context, ICU</td><td>Low</td><td>Confident mistakes if you skip validation</td></tr>
        <tr><td>Translation management system (Crowdin, Lokalise, Phrase)</td><td>Teams with translators and many locales</td><td>Monthly per seat or key</td><td>Setup and sync between the platform and your repo</td></tr>
    </tbody>
</table>

<p>
    For a team of developers without dedicated translators, a script built on an LLM or a translation API plus validation gets you most of the way. For a deeper look at the platform options, see <a href="/blogs/lokalise-crowdin-alternatives-for-developers/">Lokalise and Crowdin alternatives for developers</a>.
</p>

<h2>Or Let Babelize Run the Whole Pipeline</h2>

<p>
    Everything above is what <a href="/">Babelize</a> (our product) does when you run it in a repo. In a React, Next.js or static HTML project (Vue and React Native are in beta), one command reads your source locale file, keeps your existing keys and translations, and translates the rest:
</p>

<pre><code>npx babelize translate --to de,fr,ja
git diff</code></pre>

<ul>
    <li><strong>Protection:</strong> placeholders such as <code>{name}</code>, <code>{{count}}</code> and <code>%s</code>, tags and brand names are swapped for opaque tokens before translation.</li>
    <li><strong>Validation:</strong> every value is checked for lost placeholders, invalid ICU, missing tags and untranslated output. A key that fails is left out of the file and retried next run, never filled with English.</li>
    <li><strong>Lockfile:</strong> source text is hashed, so reruns only translate new or changed strings.</li>
    <li><strong>Review:</strong> the result lands in your working tree as a normal diff, or as a pull request through the GitHub App.</li>
</ul>

<p>
    It also does the step before all of this: if your strings are still hardcoded in components, it extracts them into the JSON file first. Use your own Anthropic, OpenAI, Gemini, Groq or OpenRouter key and you don't need an account. The limits, to be fair: it works on a git repo in one of those frameworks, not on a standalone JSON file, and it writes JSON and gettext <code>.po</code> files only (no YAML, XLIFF or mobile string files). The <a href="https://docs.babelize.co/cli/translate">translate command docs</a> list every flag.
</p>

<p>
    Translating locale files for a mobile app? The <a href="/blogs/react-native-i18n-expo-i18next/">React Native i18n guide</a> shows how to bundle them with an Expo app and pick the device language.
</p>

<h2>Final Checklist</h2>

<ul>
    <li>Translate values only; keys stay identical in every locale.</li>
    <li>Protect placeholders, tags and ICU structure before translating.</li>
    <li>Give each language its own plural forms instead of copying the source's.</li>
    <li>Validate keys, placeholders and ICU, and leave failed keys out instead of filling them with English.</li>
    <li>Write files atomically, and run the same validation in CI.</li>
    <li>Hash source strings so later runs only translate what changed.</li>
    <li>Have a native speaker review the strings users see most: sign-up, checkout and errors.</li>
</ul>
---
- [All articles](https://www.babelize.co/blogs)