jsoni18nlocalizationfrontend

Structuring i18n Translation JSON Files the Right Way

·9 min read·JSON Concepts

Translation files are JSON dictionaries with rules

Almost every internationalization (i18n) library — react-i18next, next-intl, vue-i18n, Flutter's intl — stores translated strings as JSON: one file per language, each a key-to-string dictionary. The format looks trivial with two languages and a handful of strings; the real design decisions only surface once you have 20 languages, pluralization, and strings with dynamic values.

The basic shape

json
// en.json
{
  "greeting": "Hello, {{name}}!",
  "cart": {
    "empty": "Your cart is empty",
    "itemCount": "{{count}} item in your cart"
  }
}
json
// fr.json — same keys, translated values
{
  "greeting": "Bonjour, {{name}} !",
  "cart": {
    "empty": "Votre panier est vide",
    "itemCount": "{{count}} article dans votre panier"
  }
}

The keys never change across languages — only the values. This is what lets a translator work on fr.json in isolation without touching application code, and what lets tooling diff two language files to find missing translations.

Flat keys vs. nested keys

You'll see both conventions in the wild, and the choice matters as files grow:

json
// Nested — organizes by feature, mirrors component structure
{ "cart": { "empty": "Your cart is empty", "checkout": "Checkout" } }
json
// Flat with dot-notation keys — simpler diffs, no nesting depth limit
{ "cart.empty": "Your cart is empty", "cart.checkout": "Checkout" }

Nested is more readable for humans browsing the file; flat keys produce cleaner git diffs (changing one string touches one line, not a nested block) and avoid ambiguity between "is this a nested object or a key that happens to contain a dot." Most libraries (react-i18next, next-intl) support both — pick one convention per project and enforce it, since mixing them in the same file is a common source of "key not found" bugs.

Interpolation: injecting dynamic values

Static strings only get you so far — real apps need to insert a username, a count, or a formatted date into a translated sentence. Every library uses some placeholder syntax inside the string value itself:

javascript
// react-i18next
t("greeting", { name: "Ada" }); // "Hello, Ada!"
json
// next-intl uses ICU MessageFormat syntax in the JSON value
{ "greeting": "Hello, {name}!" }

The value is still a plain JSON string — the {{name}} or {name} placeholder is parsed by the i18n library at render time, not by JSON.parse.

Pluralization: the part that isn't just string substitution

"1 item" vs "2 items" seems trivial in English, but many languages have 3, 4, or even 6 plural forms (Arabic has six: zero, one, two, few, many, other). The ICU MessageFormat standard, which next-intl and Format.js use directly in the JSON value, handles this correctly:

json
{
  "itemCount": "{count, plural, =0 {No items} one {# item} other {# items}}"
}

react-i18next instead uses a naming convention — separate keys with a suffix per plural category — which its runtime picks between based on the count and the current locale's plural rules:

json
{
  "itemCount_one": "{{count}} item",
  "itemCount_other": "{{count}} items"
}

Hardcoding count === 1 ? "item" : "items" in application code is the classic i18n bug — it's correct for English and silently wrong for the ~40% of languages with different pluralization rules.

Namespacing large apps

Loading one giant en.json for an app with hundreds of screens wastes bandwidth on strings the current page never uses. Both react-i18next and next-intl support namespaces — splitting translations into multiple files loaded on demand:

text
locales/
  en/
    common.json      (shared: buttons, nav)
    checkout.json     (only loaded on the checkout page)
    dashboard.json
  fr/
    common.json
    checkout.json
    dashboard.json
javascript
const { t } = useTranslation("checkout"); // only loads checkout.json
t("payment.title");

Flutter's ARB format: JSON with metadata

Flutter's Application Resource Bundle (.arb) files are JSON with a twist — keys prefixed with @ carry metadata (description, placeholder types) alongside the translatable @-less key, which translation tools use for context:

json
{
  "welcomeMessage": "Welcome, {name}!",
  "@welcomeMessage": {
    "description": "Greeting shown on the home screen after login",
    "placeholders": { "name": { "type": "String" } }
  }
}

Finding missing or stale translations

The single most common i18n bug in production is a key that exists in en.json but was never added to fr.json — the app silently falls back to English (or shows the raw key) for that one string. Since both files are just JSON objects with the same keys, this is a structural diff problem:

  1. Paste en.json and fr.json into JSONKit's JSON Diff tool to see exactly which keys exist in one but not the other.
  2. Use the JSON Flatten tool to turn nested translation keys into dot-notation for an easier side-by-side key comparison.
  3. Validate that a translation file is syntactically correct JSON before deploying with the JSON Validator — a single missing comma breaks every string in that language.

Frequently asked questions

Short, stable identifiers (cart.checkout) are safer long-term — if the English copy changes, you edit the value, not every key across every language file and every call site that references it.

Use ICU MessageFormat plural syntax (next-intl, Format.js) or your library's plural-suffix convention (react-i18next's _one/_other) rather than hardcoding a singular/plural check — some languages need more than two forms.

Yes — since every language file should have identical keys, diffing two files (by eye, by script, or with a JSON diff tool) against your base language file catches gaps before they reach production.

No — some ecosystems use .po/.pot (gettext) or YAML (Rails' i18n), but JSON dominates the JavaScript/TypeScript and Flutter ecosystems because it needs no extra parser and loads natively via import/fetch.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.