BookTranslator
BookTranslator

Nxaniso lowu a wu kumeki hi ririmi ra wena sweswi, hikwalaho wu kombisiwa hi Xinghezi.

How to Translate a JSON Language File Without Breaking Keys or Placeholders

See which values in an en.json file get translated, what a file translator rewrites (comments, key order, large IDs), and how to check placeholders, ICU plurals, and keys with a free script and a test file.

BookTranslator

BookTranslator Team

13 wa timinete to hlaya

To translate a JSON language file such as en.json, give the whole file to a translator that understands JSON. Pasting the raw text into a chat window or a web translator is the risky way to do it. A JSON-aware translator reads the file, translates only the text values, and writes the same keys back, so your app can still load the file. Copy and paste can translate a key, change a quote mark, or drop a brace. Any of those breaks the file.

Keeping the keys is the easy part. Most of the trouble comes after: a {name} placeholder that comes back as {nom}, an ICU plural that loses its other case, a URL sent for translation like any other text, and a few changes the translator makes to the file itself. Those are what this guide covers.

We built a test file with every one of those cases in it and wrote a free checker that compares a source file with its translation. We also ran the test file through a local copy of the JSON parsing and writing code BookTranslator uses, with a marker in place of a model, so you can see exactly which values are sent and how the file comes back.

Quick Answer: What Happens to Each Part of the File

Part of the fileExample from our testWhat BookTranslator doesWhat to check
Keys"greeting", "error"Kept as they areNothing
Plain text values"Reading list"TranslatedMeaning, length on screen
Values with placeholders"Welcome back, {name}!", "Saved %s of %d chapters"Translated, placeholders included in the text sentPlaceholder names and counts (the checker does this)
ICU plural messages"{count, plural, one {# book} other {# books}}"Sent as one stringThe other case, and plural categories for the target language
Inline HTML"accept the <b>Terms</b>."Translated; the tag count is checked after translationTag names and counts (the checker does this)
URLs stored as text"https://example.com/help"Sent for translationThat it came back unchanged
Values with no letters"2.4.1", "β€”", ""Left as they wereNothing
Numbers, true/false, null20, trueLeft as they wereVery large IDs (see below)
Arrays of strings["Open", "Save", "Export as EPUB"]Translated in the translated file; left untranslated in the bilingual fileThe bilingual file, if you use it
Comments (JSONC)// Shown on the home screenRemoved. If a value also contains //, the file can fail to loadRemove comments before upload

The rest of this guide shows where each row comes from and what to do about it.

Step 1: Check the Source File Before You Upload

Run the checker on the file you plan to translate. It needs Python 3 and nothing else:

python3 check_i18n_json.py en.json

On our test file it printed:

WARN  limits.projectId: 9007199254740993 is above 2^53 and can change in a JavaScript round trip; store IDs as strings
WARN  error.network: a flat key with dots and a nested key resolve to the same dot path; tools that address strings by dot path can mix them up
INFO  (root): 3 keys look like integers; JavaScript sorts those first and in numeric order when it rewrites the object
INFO  source: 17 strings, 2 numbers

The two warnings and the note about integer-like keys each match a change we saw when our test file went through the translator. If your file gets none of them, you can skip to Step 3.

The checker also refuses a file that is not strict JSON, such as one with comments or trailing commas, because that is the next problem.

What Came Back From Our Test File

The test ran on November 2, 2026, against the current JSON handling in BookTranslator's backend. Instead of calling a model, the test replaced every value that would be sent for translation with [ZH] plus the original text. That keeps the result predictable: anything with [ZH] in front was sent, anything without it was not. Both outputs are published: the translated file and the bilingual file.

The translated file

Every key came back unchanged, and the file still parsed. Values with letters were sent, including {name}, %s, the ICU message, the <b> tag, and the help URL. The version string "2.4.1", the dash, and the empty string were not sent, because they contain no letters. Numbers and booleans were left alone.

Four other things changed:

  1. One string went into the wrong key. In our test, our file had a flat key "error.network" with the text "Check your connection." and also a nested "error": { "network": "Network error" }. The translator identifies each string by its path with dots between the parts, so both strings had the same address, error.network. In the output, both keys held the translation of "Network error". "Check your connection." was gone. This needs both styles in the same file, which is rare, but it fails silently. The checker warns about it before you upload.
  2. Integer-like keys were reordered. "steps": { "10": ..., "2": ..., "1": ... } came back as "1", "2", "10". JavaScript always lists integer-like keys first, in numeric order, when it rebuilds an object. The keys and values still match, so your app is fine, but a diff against the source file will be noisy.
  3. A large ID lost precision. 9007199254740993 came back as 9007199254740992. JavaScript stores numbers as 64-bit floating point, and integers above 2^53 cannot all be represented exactly (MDN on Number.MAX_SAFE_INTEGER). Store long IDs as strings, or keep them out of the language file.
  4. The formatting was rewritten. The output uses two-space indentation and ends with a newline. If your source used tabs or four spaces, expect whitespace changes in the diff.

Our test file had no duplicate keys, but they are worth knowing about: JavaScript's JSON parser keeps only the last value for a repeated key, so the earlier text is never translated. Your app would load the same last value, so nothing visible breaks, but the checker lists duplicate keys so you can clean them up.

The bilingual file

The bilingual file keeps each original value and adds a sibling key with _translated on the end:

"greeting": "Welcome back, {name}!",
"greeting_translated": "[ZH] Welcome back, {name}!",

That works well for review, because the source and the translation sit side by side. It is not a file your app can load, because every key now has a twin. Use the translated file in your app.

One gap: strings inside arrays get no _translated sibling, because array items have no key to add one to. In our test, "menu": ["Open", "Save", "Export as EPUB"] came back in English in the bilingual file, but translated in the translated file. If you review from the bilingual file, check arrays in the translated file.

Files with comments

Many projects keep comments in their language files (JSONC), which strict JSON does not allow. The translator first tries strict JSON. If that fails, it removes comments and trailing commas and tries again. Removing comments means deleting everything after // on a line, and that includes the // in https://.

Our file with a comment and a URL did not load at all; parsing failed at the first comment. A file with comments but no // inside any value loaded fine, and the comments were dropped from the output.

So: if your file has comments, remove them before you upload, or keep a copy with the comments and accept that the translation will not have them.

Step 2: Prepare the File

You can fix most of these problems before you translate:

  • Make it strict JSON. Remove comments and trailing commas. If your build tool reads JSONC, keep the commented file as your source and export a clean copy for translation.
  • Use one key style. Either nest keys or flatten them with dots, not both in one file. If you use i18next, its keySeparator option controls how dots in keys are read, so check that setting before you flatten anything.
  • Move non-text data out. IDs, URLs, feature flags, and limits belong in config, not in the language file. If a URL must stay, keep it, but expect to check it after translation. Our do-not-translate guide covers how to decide what else should stay in the original language.
  • Write placeholders the same way everywhere. One style, such as {name}, is easier to check than a mix of {name}, {{name}}, and %s.

Step 3: Translate the Whole File

Upload the cleaned file to BookTranslator's document translator, which lists .json with the other localization and structured-text formats it accepts. The price is shown before checkout. When the task finishes, your history page offers the translated file and the bilingual file as separate downloads.

Two things about how the text is translated affect the result:

  • The model does not see your keys. Each value is sent as a numbered segment, without the key name. If "button.open" and "status.open" both say "Open", the model has no way to know that one is a verb and the other an adjective. Short, ambiguous strings are the ones to check by hand.
  • Each value is translated on its own. Nothing ties two keys together, so the same word in two places can come back two different ways, and a word that needs two different translations can come back the same. BookTranslator's optional auto glossary collects names, places, and specialized terms and keeps them consistent, but you cannot give it fixed translations of your own. For terms that must be translated one exact way, keep a term list and check those strings after translation; our glossary guide explains how to plan one.

Inline HTML tags are counted after translation, and a segment whose tags do not match is retried. Curly-brace placeholders, %s-style placeholders, and ICU syntax are sent as part of the text but are not checked the same way. Run Step 4 on every translation.

If you would rather translate with a chat model or a local model, send only the values, never the raw file, and put them back by key with a script. The checks below apply either way.

Step 4: Check the Translated File

Compare the translation with the source:

python3 check_i18n_json.py en.json fr.json

To show what it catches, we made a French file with planted errors. It is hand-made, not model output. After the same source warnings as in Step 1, the checker printed:

ERROR app.greeting: placeholders differ; lost ['name'] extra ['nom']
ERROR app.help_url: urls differ; lost ['https://example.com/help'] extra ['https://example.com/aide']
ERROR app.items: ICU message has no 'other' case
ERROR app.legacy: placeholders differ; lost ['%d'] extra []
ERROR limits.maxFiles: type changed from int to str
ERROR limits.projectId: non-text value changed: 9007199254740993 -> 9007199254740992
INFO  menu[1]: identical to source: 'Save'
INFO  compared 20 source values, 1 unchanged strings

Each of those would show up as a bug in the app: an empty name, a broken link, a crash or a raw message when the plural is rendered, a missing chapter count, a limit compared as text. The INFO line lists strings that came back unchanged. Some are fine, like brand names; "Save" in a French menu is not.

The checker compares keys, value types, non-text values, placeholders ({name}, {{name}}, %s, %1$s, %@), ICU argument names, HTML tags, and URLs. It does not judge the translation itself.

Plural cases are supposed to change

An ICU plural lists one message per plural category. English has two categories, one and other, but many languages have a different set. A good Russian translation of our items message would normally use one, few, many, and other, and a Japanese one only other. So a mismatch here is not always an error.

The categories come from the Unicode CLDR plural rules, and other is the one every language has. These are the cardinal categories reported by Intl.PluralRules in Node.js with CLDR 48:

LanguagePlural categories
English, Germanone, other
French, Spanish, Portugueseone, many, other
Russian, Polishone, few, many, other
Arabiczero, one, two, few, many, other
Japanese, Chinese, Koreanother

French, Spanish, and Portuguese use many only for very large round numbers, such as a million, so a message for small counts can usually do without it. The checker reports a missing other as an error, and any other change in categories as a warning to compare against this table. You can print the list for any language yourself:

node -e 'console.log(new Intl.PluralRules("ru").resolvedOptions().pluralCategories)'

The translator is given the English cases, so check what came back. If a Russian or Arabic message still has only one and other, add the missing cases by hand, or have a translator do it.

Then load it in the app

The checker proves the file is safe to load, not that it reads well. Switch the app to the new language and look at the screens: long words in German or Finnish that overflow a button, right-to-left text in Arabic or Hebrew, and dates or numbers your code formats separately from the language file. Our guide to translation versus localization covers what else changes between markets besides the words.

Common Problems

The translated file will not load in my app. Run the checker on it. If the checker cannot parse it, the file was edited after translation or was not valid to begin with. If it parses, look for ERROR lines about keys or types.

A string shows up as a raw ICU message, like {count, plural, ...}. The ICU syntax broke in translation, usually a missing brace or a missing other case. Copy the structure from the source and translate only the words inside each case.

My file is YAML, a .po file, or Markdown. BookTranslator accepts those too, but each format has its own rules. For documentation, see our Markdown translation guide; for web pages, the HTML file guide.

Can I translate only the new keys? Not inside one upload; the whole file is translated. Put the new keys in a separate file, translate it, and merge the result by key. The checker will confirm the merged file has every key.

Should I translate the keys? No. Keys are identifiers your code looks up. Only the values are text for people to read.

Matsalwa lama fambelanaka