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
// en.json
{
"greeting": "Hello, {{name}}!",
"cart": {
"empty": "Your cart is empty",
"itemCount": "{{count}} item in your cart"
}
}// 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:
// Nested — organizes by feature, mirrors component structure
{ "cart": { "empty": "Your cart is empty", "checkout": "Checkout" } }// 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:
// react-i18next
t("greeting", { name: "Ada" }); // "Hello, Ada!"// 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:
{
"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:
{
"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:
locales/
en/
common.json (shared: buttons, nav)
checkout.json (only loaded on the checkout page)
dashboard.json
fr/
common.json
checkout.json
dashboard.jsonconst { 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:
{
"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:
- Paste
en.jsonandfr.jsoninto JSONKit's JSON Diff tool to see exactly which keys exist in one but not the other. - Use the JSON Flatten tool to turn nested translation keys into dot-notation for an easier side-by-side key comparison.
- 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.