What is i18n JSON?
i18n JSON is the de-facto format for storing software UI translations as plain JSON files, with locale codes as filenames and key paths as identifiers. Most modern web and mobile i18n libraries read it natively, which is why it has quietly won as the default for new projects.
The 30-second version
i18n JSON is a plain JSON file (one per locale) where each key
identifies a translatable string and each value is the translation in
that locale. A typical project ships one file per language — en.json,
de.json, cs.json — and the i18n library swaps the active file based
on the user's locale.
The smallest possible example:
{
"checkout.title": "Checkout",
"checkout.cta": "Pay now"
}
That's the whole format. Everything else — nested objects, ICU plural rules, namespaces, fallback chains — is a dialect layered on top.
Why JSON won over .po, .yml, and .xliff
Three reasons, roughly in order of importance.
JavaScript ate the frontend. When i18next (2011) and react-intl
(2014) standardised on JSON for browser bundles, every framework that
followed (Vue I18n, ngx-translate, FormatJS, Solid, Svelte i18n) inherited
the choice. JSON parses natively in every JavaScript runtime; YAML and
XLIFF need a library. For a frontend i18n library, JSON is the lowest-
friction format that ships.
Mobile followed. When Apple introduced String Catalogs
(.xcstrings) in 2023, the underlying storage was JSON. Flutter's
.arb files are JSON. Android's strings.xml is still XML, but every
"modern Android" stack — Compose, KMP, Jetpack — has shipped JSON-based
alternatives.
It's diffable. Translators rarely touch JSON directly, but engineers
review translation changes in pull requests, and JSON diffs cleanly in
every PR viewer. .po diffs are noisy; .xliff diffs are nearly
unreadable; YAML diffs are merge-conflict-prone because of indentation.
Four common dialects you'll actually meet
"i18n JSON" isn't one format — it's a family. The four dialects below cover ~95% of what you'll see in real projects.
Flat (dotted keys)
One level deep. Keys carry their hierarchy in the string itself with dots or colons. Simple, predictable, no library-specific syntax.
{
"checkout.title": "Checkout",
"checkout.cta": "Pay now",
"checkout.error.declined": "Card declined"
}
Use when: you want the most portable format. Every i18n library can read flat JSON. No nested-object parsing logic, no library-specific escaping. Best for teams that want freedom to swap libraries later.
Nested
Same data, expressed as nested objects. The dot becomes structural.
{
"checkout": {
"title": "Checkout",
"cta": "Pay now",
"error": {
"declined": "Card declined"
}
}
}
Use when: your i18n library prefers it (Vue I18n, react-intl, and ngx-translate all default to nested). The diff cost is slightly higher than flat (a renamed key touches multiple lines), but readability in large files is better.
ICU MessageFormat
Same dialect of JSON (flat or nested), but the values carry ICU MessageFormat syntax for pluralization, gender, and number formatting.
{
"cart.items": "{count, plural, =0 {Empty cart} one {# item} other {# items}}",
"user.greeting": "{gender, select, female {Welcome back} male {Welcome back} other {Welcome back}}, {name}"
}
Use when: you need correct pluralization across languages with
complex plural rules (Russian, Arabic, Polish, Czech all have plural
categories beyond one/other). Pair with formatjs or messageformat.
i18next-style
Specific to the i18next library: keys can use _one / _other
suffixes for plurals, _male / _female for context, and namespaces
via separate files. Interpolation uses {{name}} syntax.
{
"cart_one": "{{count}} item in cart",
"cart_other": "{{count}} items in cart",
"greeting": "Hello, {{name}}!"
}
Use when: your stack is committed to i18next. The format is more
ergonomic than ICU for simple plurals but locks you to one library's
conventions.
Where i18n JSON runs out of road
JSON is fine for the storage layer, but four problems show up consistently in production teams.
No comments. JSON doesn't support comments, so translator
context — "this string appears on the checkout button" — has to live
somewhere else: a sibling _context key, a separate YAML file, or
screenshots in your TMS. Some teams use JSON5 or JSONC for source
files and strip comments at build time.
Pluralization is a separate problem. Flat JSON gives you a key →
string mapping. It doesn't tell you that Russian has four plural
categories (one, few, many, other) or that Arabic has six.
Either layer ICU MessageFormat on top, or accept that the i18n library
handles plural rules for you — but the JSON file alone is not enough.
Interpolation syntax is library-specific. {name}, {{name}},
%name%, <name/> — every library picks one, and the JSON file binds
you to that choice. If you switch from react-intl ({name}) to
i18next ({{name}}), every value needs a transform.
Missing-key behaviour is yours to define. What happens when the user's locale doesn't have a translation? Fall back to the source language? Show the key path? Show an empty string? The JSON doesn't tell you; the i18n library does — and the choice matters more than you'd expect for QA.
How LangSync stores it
LangSync treats i18n JSON as the source of truth. Each namespace maps to a directory in your repo:
i18n/
web/
en.json
de.json
cs.json
mobile/
en.json
de.json
In v1, LangSync supports flat JSON only — {"checkout.title": "Checkout"}
with dotted keys at the top level. Nested objects, ICU MessageFormat as
a dialect distinction, and i18next-style suffix plurals (cart_one /
cart_other) are on the roadmap but not shipped today. If your project
already uses one of those shapes, you'll either flatten on import or
wait for the format to land.
The reason to start with flat is that it's the most portable shape
across i18n libraries — no nested-object parsing logic, no library-
specific escaping, smallest diff cost. The format field in
.langsync.json is designed to accept additional dialects without a
breaking change once they ship.
On placeholders and plurals: LangSync's translation prompt instructs
the AI to maintain formatting and special characters, which preserves
interpolation tokens like {name} or {{name}} in practice. ICU
{count, plural, …} blocks are preserved when present in the source,
but the AI does not proactively generate ICU plural structure for
languages with extra plural categories (Russian's one / few /
many / other, Arabic's six, Czech's three). If correctness across
complex plural rules matters for your project, structure source
strings as ICU MessageFormat upfront rather than expecting the AI
to invent the block shape. See the ICU MessageFormat
guide for the syntax and the
per-language plural categories you'll need.
Common questions about i18n JSON
Should I use flat or nested JSON?
Do I need ICU MessageFormat?
one / other (Russian, Arabic, Polish, Czech, etc.) AND you have strings with counts. For a UI without dynamic counts, plain JSON is enough. For "5 items in cart," you need either ICU or a library-specific plural convention.How do I add translator context if JSON has no comments?
_context key — "checkout.cta": "Pay now" plus "checkout.cta._context": "Primary button on the checkout page". (2) Use a separate context YAML file mapped by key. (3) Attach screenshots in your TMS (LangSync, Crowdin, Phrase, etc. all support this). Option 3 is the most reliable because translators see the rendered string, not just text.Why not use `.po` (gettext)?
.po is mature and has excellent tooling for desktop and server-side i18n (PHP, Python, Ruby ecosystems still use it heavily). For new frontend or mobile projects, the diff cost, the need for a .po parser at runtime, and the lack of native JS support push teams toward JSON. If you're already on .po and it works, there's no urgent reason to switch.What about XLIFF?
Can I mix dialects in one project?
Try LangSync in 60 seconds
First 1,000 strings free, no credit card, no sales call.