How to Localize a JSON i18n File Safely: Placeholders, Plurals and ICU
Translate en.json into other languages without breaking your app: protect keys and placeholders, handle plural rules per language, keep ICU messages valid, and verify the result automatically.
- i18n
- JSON
- developers
Translating an i18n file looks trivial: it’s a list of strings. Then the translated file ships and the app shows {{nombre}} instead of a user’s name, crashes on a malformed ICU message, or says “1 items” in Polish. Every one of those bugs comes from treating a resource file like plain text.
This guide covers what has to stay untouched, how plural rules differ between languages, and how to check a translated file automatically before it reaches users.
What must never change
A typical file looks like this:
{
"nav": {
"home": "Home",
"settings": "Settings"
},
"greeting": "Hello, {{name}}!",
"cart": {
"summary": "You have {count, plural, one {# item} other {# items}} in your cart"
},
"legal": "By continuing you agree to our <link>Terms</link>."
}
Only the human-readable parts are translatable. These must survive byte-for-byte:
- Keys —
nav.home,greeting. Your code looks them up; translate a key and the lookup fails silently and shows a fallback or the key itself. - Nesting and structure — the same objects and arrays in the same shape.
- Placeholders —
{{name}},{name},%s,%1$s,%{name},:name,${name}, depending on your library. - ICU syntax — the words
plural,select,one,other, the#symbol and the braces. - Markup —
<link>,<b>,<0>(react-i18next’sTranscomponent) and HTML entities. - Escapes —
\n,\",\t. A translator who “fixes”\ninto a real line break produces invalid JSON.
Know your placeholder syntax
| Library / platform | Placeholder style |
|---|---|
| i18next | {{name}} |
| ICU MessageFormat (FormatJS, react-intl, many others) | {name} |
| vue-i18n | {name} |
| Rails I18n (YAML) | %{name} |
| Android string resources | %s, %1$s, %d |
| iOS / C-style format strings | %@, %d, %1$@ |
| Laravel | :name |
Positional placeholders like %1$s and %2$s matter for translation: word order changes between languages, and positional placeholders let the translator reorder them. If your source uses plain %s twice, consider switching to positional ones before localizing.
Placeholders can move, but not change
Translators should be free to move a placeholder — that’s normal grammar:
"en": "Uploaded {count} files to {folder}",
"ja": "{folder}に{count}個のファイルをアップロードしました"
What they must not do is translate the placeholder name ({folder} → {carpeta}), drop it, duplicate it, or change its type. A useful rule: the set of placeholders in source and translation must be identical, even if the order differs.
Plurals: English is the simple case
English has two plural forms: one (1 item) and other (0 items, 2 items). Many languages have more, some have fewer. The Unicode CLDR defines the categories every major library uses: zero, one, two, few, many, other.
| Language | CLDR cardinal categories |
|---|---|
| English, German, Spanish | one, other |
| French | one, many, other |
| Russian, Ukrainian | one, few, many, other |
| Polish | one, few, many, other |
| Arabic | zero, one, two, few, many, other |
| Vietnamese, Japanese, Chinese, Korean | other |
Two practical consequences:
- A translation can need more forms than the source. If your English string has
oneandother, the Polish translation must addfewandmany, or users will see the wrong form for numbers like 2, 3 and 4. - A translation can need fewer. Vietnamese uses only
other, so a Vietnamese ICU message can simply be{count, plural, other {# sản phẩm}}.
ICU plural example
EN: {count, plural, one {# file} other {# files}}
PL: {count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}
i18next plural keys
Since i18next v21, plurals use suffixed keys based on the same CLDR categories:
{
"file_one": "{{count}} file",
"file_other": "{{count}} files"
}
A Russian file then needs file_one, file_few, file_many and file_other. A translation workflow that only copies the source’s keys will miss them. Older i18next projects using the _plural suffix follow a different scheme, so check your configured JSON format version.
Select and gender
ICU select chooses text by a value such as gender or role:
{gender, select, female {She replied} male {He replied} other {They replied}}
Translate the text inside each branch; never translate the branch keys (female, male, other). Always keep the other branch — it’s required.
Things that break silently
- Concatenated strings.
"Delete" + " " + itemName + "?"can’t be translated correctly because word order varies. Use one string with a placeholder. - Reused strings. The English word “Open” may be a verb on a button and an adjective on a status badge; many languages need different words. Give them separate keys.
- Missing context. “Back” (navigation) vs “back” (body part). Add a description or comment field if your format supports one.
- Length. German labels can be much longer than English ones; Vietnamese also tends to run longer. Check tight UI spots.
- Encoding. Save as UTF-8. Some tools add a byte-order mark (BOM), which a few JSON parsers reject.
Verify automatically
Never ship a translated resource file you haven’t checked by machine. A small script catches most problems:
// check-i18n.mjs — usage: node check-i18n.mjs en.json vi.json
import { readFileSync } from "node:fs";
const [src, tgt] = process.argv.slice(2).map((f) => JSON.parse(readFileSync(f, "utf8")));
const flatten = (o, p = "") =>
Object.entries(o).flatMap(([k, v]) =>
v && typeof v === "object" ? flatten(v, `${p}${k}.`) : [[`${p}${k}`, String(v)]]
);
const PH = /\{\{\s*\w+\s*\}\}|\{\w+\}|%(\d+\$)?[sd@]|%\{\w+\}|<\/?\w+>/g;
const tokens = (s) => (s.match(PH) ?? []).sort().join(" ");
const a = new Map(flatten(src));
const b = new Map(flatten(tgt));
let errors = 0;
for (const [key, text] of a) {
if (!b.has(key)) { console.log(`missing: ${key}`); errors++; continue; }
if (tokens(text) !== tokens(b.get(key))) {
console.log(`placeholders differ: ${key}\n ${text}\n ${b.get(key)}`);
errors++;
}
}
for (const key of b.keys()) if (!a.has(key)) console.log(`extra key: ${key}`);
process.exit(errors ? 1 : 0);
This script is intentionally simple: it treats ICU plural messages as plain strings, so it will flag plural messages whose translation has a different number of branches. For ICU-heavy projects, also parse every message with a real parser — for example @formatjs/icu-messageformat-parser — and fail the build on syntax errors. Add the check to CI so a broken translation can’t be merged.
Beyond the script:
- Parse the file with your app’s actual loader.
- Pseudo-localize — generate a fake locale with accented, lengthened strings (
[Ŝéţţîñĝš ~~~]) to find hard-coded text and layout overflow before real translations arrive. - Click through the app in the new locale, especially screens with counts, names and dates.
Where AI translation fits
Modern AI translation is good at i18n strings when it gets context and constraints: what the app does, which placeholders are protected, and which plural categories the target language needs. It’s poor at it when strings are pasted one by one without context.
That’s the gap 2Translate is designed for: upload en.json or a YAML file, pick target languages, and get back a file with identical keys and placeholders, with plural forms expanded for the target language. Run the same checks on its output as on anyone else’s.
If you’re localizing a whole product, our website and app localization checklist covers the rest: dates, currency, fonts, SEO and testing.