How to Translate a JSON Locale File Without Breaking Keys
To translate a JSON locale file, use a tool that walks the file and translates the string values only, leaving every key, every level of nesting, and every interpolation placeholder untouched. The JSON File Translator does exactly that, and it protects tokens like {{name}}, {count}, and %s before the model ever sees the string.
You have an en.json and you need an es.json. It looks like the easiest file you will ever translate, because it is already just a list of strings. So you paste it into a general translator and get back something that looks right until you load it. The keys came back in Spanish, so every lookup in your code misses. The nesting flattened. And somewhere in the middle, {{name}} became {{nombre}}, which means your greeting now renders a literal {{nombre}} to every user who signs in.
A locale file punishes general-purpose translation harder than a document does, because a document is all prose and a locale file is only about half prose. The other half is machinery.
A locale file has two halves
Open any en.json and you are really looking at two different kinds of text sitting in the same file.
The keys are identifiers. Your code calls t('welcome.greeting'), and that string has to match byte for byte. It is not language, it is an address. Translate it and the lookup fails, which usually shows up as the raw key printed on screen, or an empty string where your headline should be.
The values are the only thing a user ever reads. They are the entire point of translating the file.
Then there is a third thing hiding inside the values: interpolation placeholders. Every i18n library has its own syntax, and they all break the same way. {{name}} in i18next and Vue I18n, {count} in ICU and .NET, %s and %1$s in printf and Android, ${value} in template literals. A translation model sees {{name}} as a word, and words are what it was built to change.
So the rule is narrow and strict. Translate the values, keep the keys, and make sure the placeholders come back byte for byte.
Translating the file
The JSON File Translator works to that rule. Upload a .json, pick a language, download the result. Keys are never sent for translation, so there is no path by which they come back changed.
- Open the JSON File Translator.
- Drop your
.jsonfile in, or click to browse for it. - Pick the language you want under To (leave From on Auto-detect if you are not sure).
- Click Translate File, then download the result.

The file is validated in your browser before anything is uploaded, so a stray trailing comma or a comment gets caught immediately instead of failing halfway through. Worth knowing: JSON with comments or trailing commas is not valid JSON, so if your file is really a .jsonc, strip those first.
Before you translate, open the glossary box. This is the step most people skip and then regret across a thousand strings.

Two lines here do two different jobs. Acme Cloud = Acme Cloud pins your product name so it is never translated anywhere in the file. Dashboard = Panel forces one consistent choice for a UI noun that a model would otherwise render three different ways across your nav, your empty states, and your onboarding copy. Locale files are exactly where that inconsistency shows up, because the same word appears in dozens of unrelated strings.
What actually comes back
Here is the real result from that file, original on the left, Spanish on the right.

Read down the two columns and you can check the whole contract at once.
Every key is identical. app, nav, dashboard, welcome, greeting, shareNotice, planLabel. The nesting is identical, four levels deep in the settings block. The header counts 9 placeholders kept, and you can see them individually: {{name}} in the greeting, {{count}} in both plural forms, {{plan}} and {{date}} in billing, %s in lastEdited. All present, all spelled the same, all in a sentence that has been rearranged around them into Spanish word order.
A few things stayed in English on purpose, and each one would have been a bug if it had not. version is still 4.2.1. invoiceUrl is still a URL. supportEmail is still an email address. ERR_NETWORK_TIMEOUT is still an error code your code switches on, sitting right next to network, whose value is user-facing prose and did get translated. Same object, two different treatments, decided by what the value actually is.
The glossary held too. Acme Cloud is untouched in the output, and Dashboard came out as Panel in the nav, which then carried into cta as “Ir a tu panel”.
The one to look at closely is shareNotice. The English is Invite your team from <b>Settings</b> to work together. and the Spanish is Invita a tu equipo desde <b>Configuración</b> para trabajar juntos. The inline markup survived as markup, the text inside it was translated as text, and the tags did not migrate to a different position in the sentence. That is the case that breaks most often when people translate locale files by hand.
What it leaves alone, and why
The rule is that anything which is not user-facing prose does not change:
- Every key, at every level of nesting
- URLs, email addresses, file paths, hex colors, dates, and version numbers
- Code-style values like
ERR_NETWORK_TIMEOUToruser.profile.title - Values under technical keys such as
id,url,icon,class, andslug - Types. Numbers stay numbers, booleans stay booleans, nulls stay null, arrays keep their order
Output formatting is preserved as well, which matters more than it sounds. The indent width and the trailing newline match your input, so when you open the pull request the diff is only the strings. Nobody has to review a 400-line reformat to find the twelve lines that actually changed.
The ICU plural caveat
One honest limitation. If your file uses ICU plural or select blocks, the block comes back structurally intact and valid, but the wording inside stays in the source language.
This is deliberate. Plural categories are not the same across languages. English has two, Spanish has two but splits differently, Russian has four, Arabic has six. A model asked to translate an ICU block will confidently produce a one/other pair for a language that needs one/few/many/other, and your app will render the wrong string for a whole class of numbers. That failure is silent and it is a pain to trace.
Leaving those blocks for a human is the safer trade. A missing translation is visible in five seconds. A plural rule that is wrong only for 2 through 4 is a bug report six weeks later.
Note that i18next-style suffix plurals are a different thing and translate normally. In the file above, count_one and count_other are ordinary keys with ordinary values, and both came back in Spanish with {{count}} intact.
Tips for a clean localization pass
- Write the glossary first. Product names, UI verbs, and anything with a house style. Pinning a term to itself is how you say do not translate this.
- Pick a specific variant. Latin American Spanish and European Spanish are separate options, and the register is genuinely different in an app UI.
- Do one namespace file at a time. Smaller files give the model tighter context, and it keeps your diffs reviewable.
- Diff before you commit. Only string values should appear. If a key shows up in the diff, something is wrong and you want to know before it ships.
- Watch your longest strings. Spanish and German run longer than English, and a button label that fit in your layout in English may not fit at 130% width.
If your project keeps its translations in YAML rather than JSON, the YAML translator does the same job with comments and anchors preserved. For a spreadsheet of strings, use the CSV translator and pick the column. And if you just need one string checked quickly, plain text translation is a paste away.
For a locale file, though, the whole thing is those four steps: upload, glossary, translate, download. Keys intact, placeholders intact, free, no signup. Every format we handle is on the TranslationForFree home page.