Engineering

JSON Translation Files: Formats, Structure, and Best Practices for i18n

Eray Gündoğmuş
Eray Gündoğmuş
·13 min read
Share
JSON Translation Files: Formats, Structure, and Best Practices for i18n

JSON Translation Files: Formats, Structure, and Best Practices for i18n

Nearly every web application that ships in more than one language stores translations in JSON files. The format is ubiquitous, but the conventions around it are not uniform. Different frameworks expect different shapes: a flat key-value object, a deeply nested namespace tree, a message with embedded ICU syntax, or a Chrome extension manifest with its own descriptor structure. Choosing the wrong format early or migrating between formats later both carry real costs.

This guide covers the major JSON translation formats in use today, how to organize translation files at scale, key naming conventions, pluralization patterns, tooling for validation, and how to move between JSON and alternative formats like XLIFF and PO.


TL;DR / Key Takeaways

  • JSON is the dominant format for web i18n because it is native to JavaScript, human-readable, and works well with version control — but there is no single "JSON i18n format." Each major library defines its own conventions.
  • The most common variants are flat key-value (simple but hard to maintain at scale), nested/namespaced (organized but verbose), i18next (namespaced with built-in plural key suffixes), FormatJS/react-intl (ICU MessageFormat in values), and chrome.i18n (descriptor objects with a message field and optional description).
  • File organization matters as much as format: splitting translations by namespace or feature domain keeps files small, reduces merge conflicts, and enables lazy loading.
  • Pluralization and variable interpolation syntax varies by library — using ICU MessageFormat in your values gives you the most portable and expressive option, but requires a compatible runtime.
  • CI validation — checking for missing keys, malformed JSON, and untranslated strings — is the most effective way to prevent broken translations from reaching production.

Why JSON for Translations?

Before comparing formats, it is worth understanding why JSON became the default for web i18n — and why that choice is not always obvious.

Human-readable and diff-friendly. A JSON translation file is readable without tooling. A diff in a pull request shows exactly which strings changed, which were added, and which were removed. Compare this to XLIFF 2.0, where the same change is buried in XML attributes across multiple elements, or to compiled binary formats where diffs are unreadable.

Native to JavaScript. JSON.parse() is built into every JavaScript runtime. There is no parsing dependency to install, no format adapter to configure. When a Next.js application bundles translations for the browser, it ships plain JSON — no serialization overhead, no custom parser.

Easy to generate and consume programmatically. Every language has a JSON library. Generating translation files from a database, a spreadsheet export, or a translation management system API is a straightforward JSON serialization task.

Works well with version control. JSON files are text. They merge cleanly when changes are in different keys, they produce readable blame history, and they work with standard diff tools.

Alternatives and Their Trade-offs

FormatStrengthsWeaknesses
XLIFF 2.0Rich metadata, translator notes, state tracking, industry standardVerbose XML, poor diff readability, complex tooling
PO / GettextMature ecosystem, msgid/msgstr model supports context, plural forms built inRequires specialized parsers, less common in JavaScript
YAMLReadable, supports multiline strings naturallyIndentation-sensitive (error-prone), no native browser parser
ARB (Application Resource Bundle)Native to Flutter/Dart, supports metadata per keyDart-specific ecosystem, limited web tooling
JSONUbiquitous, native JavaScript, great toolingNo built-in metadata, plural handling varies by library

XLIFF is the right choice when translations flow through a professional translation agency with CAT tools. PO is appropriate for projects that want to leverage the Gettext ecosystem (Poedit, GNU gettext utilities). For most web applications built in React, Vue, Angular, or Svelte, JSON is the practical default.


Common JSON Translation Formats

Flat Key-Value

The simplest possible JSON translation file is a single-level object where every key is a string identifier and every value is the translated string.

{
  "welcome_message": "Welcome to our platform",
  "login_button": "Log in",
  "logout_button": "Log out",
  "error_not_found": "Page not found",
  "error_server": "Something went wrong. Please try again."
}

This format has zero learning curve and works with any JSON parser. The problem appears at scale: a flat file with 500 keys for a large application becomes impossible to navigate. There is no visual grouping, no indication of where a string is used, and no way to extract a subset of translations for lazy loading.

Best for: Small projects, prototypes, or applications with fewer than 100 translation keys.

Nested / Namespaced JSON

Nested JSON organizes keys into a hierarchy that mirrors the structure of the application.

{
  "auth": {
    "login": {
      "title": "Sign in to your account",
      "email_label": "Email address",
      "password_label": "Password",
      "submit_button": "Sign in",
      "forgot_password": "Forgot your password?"
    },
    "logout": {
      "confirm": "Are you sure you want to sign out?"
    }
  },
  "dashboard": {
    "greeting": "Good morning, {{name}}",
    "stats": {
      "total_users": "Total users",
      "active_sessions": "Active sessions"
    }
  }
}

Libraries like vue-i18n and angular/localize support nested JSON natively. The key is accessed with dot notation: t('auth.login.title'). This structure maps naturally to component hierarchies and makes it easy to identify where each string is used.

The trade-off is that deeply nested objects require more careful key management and can produce long access paths. Most teams settle on two or three levels of nesting as a practical maximum.

i18next Format

i18next is the most widely used i18n library in the JavaScript ecosystem and has its own conventions built on top of nested JSON. The most important is its plural key suffix system.

{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "loading": "Loading..."
  },
  "items": {
    "count_one": "{{count}} item",
    "count_other": "{{count}} items",
    "count_zero": "No items"
  },
  "notifications": {
    "new_one": "You have {{count}} new notification",
    "new_other": "You have {{count}} new notifications"
  }
}

i18next resolves plural forms by appending a suffix to the base key. The suffixes follow the CLDR plural rule categories: zero, one, two, few, many, other. For English, only one and other apply. For Arabic or Russian, the full set matters.

i18next also supports namespacing at the file level: each JSON file is a namespace. When you call t('common:save'), i18next loads common.json for the active locale. This enables namespace-level lazy loading in React applications using react-i18next.

{
  "title": "Welcome, {{name}}!",
  "description": "You joined on {{date, datetime}}",
  "price": "{{price, currency}}"
}

The {{value, formatType}} syntax triggers i18next's formatting pipeline, which integrates with the Intl API for dates, numbers, and currency.

FormatJS / react-intl Format

FormatJS (which powers react-intl) uses ICU MessageFormat syntax embedded directly in string values. This puts all pluralization and variable logic inside the message string itself rather than in key suffixes.

{
  "welcome": "Welcome, {name}!",
  "item_count": "{count, plural, =0 {No items} one {# item} other {# items}}",
  "last_login": "Last login: {date, date, medium}",
  "account_status": "{gender, select, male {He is} female {She is} other {They are}} a verified member.",
  "notification_badge": "{count, plural, =0 {} one {({count})} other {({count})}}"
}

ICU MessageFormat is the most expressive option available for complex plural and gender agreement rules. The select keyword handles conditional strings, plural handles count-based forms, and format types (date, number, currency) delegate to the Intl API.

The keys are typically flat strings, often matching the component path or a semantic descriptor. FormatJS discourages deeply nested structures in favor of descriptive flat keys.

Using with react-intl:

import { useIntl, FormattedMessage } from 'react-intl';

function ItemList({ count }) {
  const intl = useIntl();
  return (
    <p>
      <FormattedMessage id="item_count" values={{ count }} />
    </p>
  );
}

chrome.i18n Format

Chrome extensions use a specific JSON format defined by the chrome.i18n API. Each key maps to a descriptor object rather than a plain string.

{
  "extensionName": {
    "message": "My Extension",
    "description": "The name of the extension, displayed in the Chrome Web Store."
  },
  "welcomeMessage": {
    "message": "Hello, $USER$!",
    "description": "Greeting shown when the extension opens",
    "placeholders": {
      "user": {
        "content": "$1",
        "example": "Alice"
      }
    }
  },
  "itemCount": {
    "message": "$COUNT$ items selected",
    "placeholders": {
      "count": {
        "content": "$1",
        "example": "3"
      }
    }
  }
}

The description field is not shown to end users — it is context for translators and the Chrome Web Store review process. Placeholders use a $PLACEHOLDER_NAME$ convention with a separate placeholders object defining the content and an example value.

This format is non-negotiable for Chrome extensions. It is not suitable for general-purpose web application i18n.


File Organization Patterns

Single File per Locale

The simplest organization is one JSON file per locale containing all translations for the application.

locales/
  en.json
  fr.json
  de.json
  ja.json
  es.json

This works for small applications but creates problems at scale: files grow large, unrelated features cause merge conflicts in the same file, and you cannot lazy-load translations for sections the user has not visited.

Namespace Splitting

The most common pattern for medium and large applications is splitting translations by namespace — one JSON file per functional area per locale.

locales/
  en/
    common.json
    auth.json
    dashboard.json
    settings.json
    errors.json
  fr/
    common.json
    auth.json
    dashboard.json
    settings.json
    errors.json
  de/
    common.json
    auth.json
    dashboard.json
    settings.json
    errors.json

Each namespace file is small and focused. The common namespace contains strings used across the application (button labels, form field labels, generic error messages). Feature namespaces contain strings specific to that feature.

i18next and react-i18next use this pattern natively: t('auth:login.title') loads auth.json for the active locale. Next.js applications using next-i18next follow this structure by convention.

Feature-Based Splitting

For very large applications, or monorepos where each package owns its own translations, a feature-based structure places translation files alongside the code that uses them.

src/
  features/
    auth/
      locales/
        en.json
        fr.json
      components/
        LoginForm.tsx
        SignupForm.tsx
    dashboard/
      locales/
        en.json
        fr.json
      components/
        DashboardHeader.tsx
    checkout/
      locales/
        en.json
        fr.json

This pattern maximizes cohesion: the translation file lives next to the component that uses it. The build system or i18n loader merges namespace files at runtime or build time. The disadvantage is that finding all translations for a given locale requires traversing the entire source tree, which complicates the workflow for translators and translation management systems.

Recommendation: Use namespace splitting for most applications. Use feature-based splitting only if your team is large enough that different squads own different features and need to avoid cross-feature merge conflicts in translation files.


Translation File Format Comparison Table

FormatReadabilityTooling SupportMetadata SupportICU SupportDiff-Friendliness
JSON (flat)HighExcellentNoneNo (by default)Excellent
JSON (nested)HighExcellentNoneNo (by default)Excellent
JSON (FormatJS)MediumGoodNoneYes (native)Good
XLIFF 2.0LowExcellent (CAT tools)Rich (state, notes, alt translations)YesPoor
PO / GettextMediumGood (Poedit, Weblate)Medium (comments, context)PartialMedium
YAMLHighMediumNoneNoGood
ARBHighGood (Flutter)Per-key metadataNoGood

XLIFF's metadata advantage is significant when working with professional translation agencies: the format tracks translation state (new, translated, reviewed, final), supports alternate translations, and is the native format for most professional CAT tools like Phrase, memoQ, and SDL Trados. If your translation workflow involves agency handoffs, generating XLIFF from your JSON source (rather than sending raw JSON) improves translator productivity and reduces errors.


Key Naming Conventions

Consistent key naming is the difference between a translation file that is easy to maintain and one that becomes a source of bugs and confusion.

Dot Notation vs. Nesting

Flat keys with dots and genuinely nested objects both represent hierarchy, but they behave differently. A flat key with dots is a single string — "auth.login.title". A nested object is a real object structure with the key title inside login inside auth. Most libraries support both, but mixing the two in the same file causes ambiguity.

Recommendation: Use genuine nesting in your JSON files and dot notation only in code when accessing keys. Do not use dots in key names within the same nesting level.

{
  "auth": {
    "login": {
      "title": "Sign in"
    }
  }
}

Accessed in code as t('auth.login.title') — the library handles traversal.

snake_case vs. camelCase

Both are common. snake_case is more readable in JSON files because words do not blur together visually. camelCase aligns with JavaScript conventions and is common in FormatJS projects.

{
  "submit_button": "Submit",
  "error_message": "An error occurred"
}
{
  "submitButton": "Submit",
  "errorMessage": "An error occurred"
}

Pick one and enforce it with a linter. Inconsistency within a project is worse than either choice.

Descriptive vs. Terse

Terse keys are short but lose context quickly:

{
  "btn_sub": "Subscribe",
  "btn_cncl": "Cancel",
  "err_404": "Page not found"
}

Descriptive keys communicate purpose without reading the value:

{
  "newsletter_subscribe_button": "Subscribe",
  "modal_cancel_button": "Cancel",
  "error_page_not_found_title": "Page not found"
}

Recommendation: Use descriptive keys that include the UI context (component type or location) and the content type (button, title, description, placeholder). This makes it possible to understand how a key is used without reading the component code.

Hierarchical Grouping

When using nested JSON, group by UI area first, then by component or action, then by element type:

{
  "checkout": {
    "cart": {
      "title": "Your cart",
      "empty_message": "Your cart is empty",
      "item_count_one": "{{count}} item",
      "item_count_other": "{{count}} items"
    },
    "payment": {
      "title": "Payment details",
      "card_number_label": "Card number",
      "expiry_label": "Expiry date",
      "submit_button": "Pay now"
    }
  }
}

Handling Plurals and Variables in JSON

ICU MessageFormat (FormatJS, vue-i18n, others)

ICU MessageFormat is the most expressive and portable syntax for plurals and variables. It is supported natively by FormatJS, and can be configured in vue-i18n and other libraries.

{
  "file_count": "{count, plural, =0 {No files} one {# file} other {# files}}",
  "upload_progress": "Uploading {current} of {total} files",
  "last_seen": "Last seen {time, date, relative}",
  "user_role": "{role, select, admin {Administrator} editor {Editor} other {Viewer}}"
}

The # symbol inside a plural branch is replaced by the formatted count. The select keyword branches on a string value rather than a number.

i18next Plural Suffix Convention

i18next resolves plurals by appending a suffix to the key. For English:

{
  "message_count_one": "{{count}} message",
  "message_count_other": "{{count}} messages",
  "message_count_zero": "No messages"
}

Called with t('message_count', { count: 5 }), i18next selects message_count_other and interpolates count. For languages with more plural forms (Russian has four), additional suffix variants are defined:

{
  "file_one": "{{count}} файл",
  "file_few": "{{count}} файла",
  "file_many": "{{count}} файлов",
  "file_other": "{{count}} файлов"
}

The CLDR plural rule categories (zero, one, two, few, many, other) map to the suffixes i18next uses.

Variable Interpolation

Every library supports some form of variable interpolation. The syntax varies:

{
  "greeting_i18next": "Hello, {{name}}!",
  "greeting_icu": "Hello, {name}!",
  "greeting_vue": "Hello, {name}!",
  "greeting_angular": "Hello, {{ name }}!"
}

i18next uses double curly braces by default. ICU uses single curly braces. Angular's $localize uses a tagged template literal in code rather than a runtime interpolation syntax in the JSON value.

Do not mix interpolation syntaxes in the same project. Pick the syntax that matches your library and enforce it in your JSON schema validation.


Tooling and Validation

JSON Schema for Translation Files

A JSON Schema definition lets you validate translation file structure in CI pipelines, editors, and pre-commit hooks. Here is a minimal schema for a flat key-value translation file:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "additionalProperties": {
    "type": "string"
  },
  "minProperties": 1
}

For nested files, the schema becomes recursive. Most teams use a combination of JSON Schema for structural validation and a dedicated i18n linting tool for semantic checks.

Linting for Missing Keys

The most common production i18n bug is a missing translation key — a key present in English but absent from a locale file. This causes a fallback to the key name itself or an empty string, which breaks the UI.

i18next-parser scans your source code for t('key') calls and generates or validates translation files:

npx i18next-parser --config i18next-parser.config.js

eslint-plugin-i18n-json validates JSON translation files directly:

npm install --save-dev eslint-plugin-i18n-json

A basic ESLint config for translation file validation:

{
  "plugins": ["i18n-json"],
  "rules": {
    "i18n-json/valid-json": "error",
    "i18n-json/sorted-keys": ["warn", { "order": "asc" }],
    "i18n-json/identical-keys": [
      "error",
      { "filePath": "src/locales/en.json" }
    ]
  }
}

The identical-keys rule checks that every locale file contains the same set of keys as the reference locale file (typically English). It flags both missing keys and extra keys that should not exist.

CI Checks for Completeness

A CI step that blocks merges when a locale file is incomplete prevents translation debt from accumulating. A simple shell script check:

#!/usr/bin/env bash
BASE="src/locales/en.json"
LOCALES=("fr" "de" "ja" "es")

for locale in "${LOCALES[@]}"; do
  FILE="src/locales/${locale}.json"
  BASE_KEYS=$(jq 'keys | length' "$BASE")
  LOCALE_KEYS=$(jq 'keys | length' "$FILE")

  if [ "$BASE_KEYS" != "$LOCALE_KEYS" ]; then
    echo "ERROR: ${FILE} has ${LOCALE_KEYS} keys, expected ${BASE_KEYS}"
    exit 1
  fi
done

echo "All locale files are complete."

For nested files, jq path traversal is required. Platforms like Better i18n use JSON as their native format and sync translations via CLI, which means the completeness check can be enforced on the platform side before translations are committed — reducing the need for CI-level checks on incomplete files.

Editor Integration

VS Code extensions like "i18n Ally" provide inline translation previews, missing key highlighting, and machine translation suggestions directly in the editor. This moves validation left — developers see missing translations while writing code rather than during CI.


Migration Between Formats

JSON to XLIFF

XLIFF is required when sending translations to a professional agency. Converting from JSON to XLIFF 2.0 involves mapping key-value pairs to <unit> elements with <segment> children.

The i18next-conv tool handles bidirectional conversion:

npm install -g i18next-conv

# JSON to XLIFF
i18next-conv -l en -s locales/en.json -t locales/en.xliff

# XLIFF to JSON
i18next-conv -l fr -s locales/fr.xliff -t locales/fr.json

For FormatJS projects, @formatjs/cli provides XLIFF export:

npx formatjs compile-folder --ast src/locales/en.json > compiled/en.json

JSON to PO

PO (Portable Object) format is used by the Gettext ecosystem and supported by tools like Poedit and Weblate. The i18next-conv tool also supports PO:

# JSON to PO
i18next-conv -l en -s locales/en.json -t locales/en.po

# PO to JSON
i18next-conv -l fr -s locales/fr.po -t locales/fr.json

For vue-i18n projects, the @intlify/vue-i18n-loader supports .po files directly as an alternative to JSON, which is useful when your Vue project shares translation memory with a server-side application using Gettext.

Common Conversion Tools

ToolConvertsNotes
i18next-convJSON ↔ PO, JSON ↔ XLIFF, JSON ↔ CSVMost complete for i18next format
@formatjs/cliFormatJS JSON → XLIFF, extracted messages → locale filesRequired for FormatJS/react-intl projects
gettext-converterPO ↔ JSON (flat)Simple, no framework assumptions
xliff-simple-mergeXLIFF merge and splitUseful for Angular projects using angular/localize
Online XLIFF editorsXLIFF ↔ JSON (via UI)Phrase, Lokalise, and Crowdin all provide import/export

When migrating between formats, validate the output before committing. Automated conversion tools occasionally lose context strings, drop comments, or mishandle plural forms for languages with non-standard plural rules.


FAQ

Which JSON format should I use for a new React project?

If you are using react-i18next (which is the most common choice), start with namespace-split nested JSON following i18next conventions. If you are using react-intl or a FormatJS-based setup, use flat keys with ICU MessageFormat syntax in values. The library you choose determines the format — pick the library first based on your project requirements, then follow its documented conventions.

Should my JSON keys be the English string itself or a semantic identifier?

Semantic identifiers (like auth.login.submit_button) are preferable for most applications. Using the English string as the key (Gettext style: t('Submit')) works for small projects but creates problems at scale: you cannot have two different "Submit" buttons with different context in the same namespace, and changing the English copy requires updating all key references in the code.

How do I handle missing translations at runtime without breaking the UI?

Every major i18n library falls back to a configured default locale when a key is missing. Configure your library to use the English locale file as the fallback, which means a missing French translation shows the English string rather than the key name. Log missing keys in development (most libraries support a missingKeyHandler callback) so they are visible during testing.

Is it safe to use comments in JSON translation files?

Standard JSON does not support comments. Some tools support JSON5 or JSONC (JSON with Comments) as an extension, but this breaks compatibility with standard JSON parsers. If you need to annotate strings with context for translators, use a separate description field at the key level (as chrome.i18n does) or maintain a separate metadata file. A simpler approach: use descriptive key names that communicate context without requiring inline comments.

How should I handle right-to-left (RTL) languages in my JSON files?

The JSON structure for RTL languages (Arabic, Hebrew, Persian) is identical to LTR languages — the file format does not change. RTL support is a CSS and layout concern, not a translation file concern. Your i18n library provides a way to detect the text direction of the active locale (typically via the Intl.Locale API or a locale metadata object), and your application applies dir="rtl" to the document or component accordingly. Keep RTL-specific CSS in your stylesheets, not in your translation values.


Conclusion

JSON translation files do not follow a single universal standard — they follow the conventions of the library that reads them. Understanding which format your library expects, and why it was designed that way, is more useful than searching for a "correct" approach.

The practical guidance is this: use nested JSON with i18next conventions if you are building a React, Vue, or Node.js application and want the largest ecosystem of tooling and examples. Use ICU MessageFormat in your values if your application must handle complex plurals, gender agreement, or rich formatting across many locales and you want the expressions to be as portable as possible. Use flat keys for small projects or rapid prototyping where cognitive overhead matters more than organizational structure.

File organization, key naming, and CI validation matter as much as format choice. A flat file with consistent naming and automated completeness checks will serve a small team better than a carefully namespaced structure with no tooling and no enforcement.

Translation debt accumulates quietly — a missing key here, a stale string there — until localized users encounter broken UI strings. The teams that avoid this outcome treat translation files the same way they treat any other code artifact: reviewed in pull requests, validated in CI, and organized with future maintainers in mind.


References


Last updated: March 2026