JSON Localization: i18n File Formats, Pluralization, and ICU Messages
Last updated:
JSON localization files store translation strings as key-value pairs — flat keys like "button.submit" or nested objects group related strings for maintainability. i18next's JSON format supports interpolation with {{variable}} syntax and pluralization with _one/_other suffixes. ICU Message Format, used by react-intl and Format.js, encodes plural rules, select conditions, and date/number formatting inside the JSON string value itself.
This guide covers flat vs nested JSON i18n files, ICU Message Format, pluralization rules by locale, next-intl file structure, automated translation with JSON, and namespace splitting for large projects.
Flat vs Nested JSON i18n Structure
Both flat and nested JSON are valid i18n file formats. The choice affects tooling, readability, and lookup performance — though the performance difference is negligible below 10,000 keys.
// Flat keys — dot notation encodes hierarchy in the key string
// messages/en.json (flat)
{
"button.submit": "Submit",
"button.cancel": "Cancel",
"nav.home": "Home",
"nav.about": "About",
"error.required": "This field is required",
"error.invalid_email": "Please enter a valid email address"
}
// Nested keys — hierarchy encoded in object structure
// messages/en.json (nested)
{
"button": {
"submit": "Submit",
"cancel": "Cancel"
},
"nav": {
"home": "Home",
"about": "About"
},
"error": {
"required": "This field is required",
"invalid_email": "Please enter a valid email address"
}
}Flat keys are easier to search with grep and simpler to manage with automated translation tools. Nested keys are more readable when browsed in an editor and match the component tree structure of most frontend apps. i18next supports both formats natively; next-intl prefers nested keys with namespace prefixes. Most translation management platforms (Lokalise, Crowdin, Phrase) support both and convert between them on export.
Tooling support: i18next-parser scans your source code for t('key') calls and generates skeleton JSON files in either format. Use the keySeparator: '.' option for flat keys, keySeparator: false to disable splitting.
i18next JSON Format
i18next is the most widely used JavaScript i18n library. Its JSON format supports interpolation, pluralization, context, and namespaces in a single file structure.
// messages/en/common.json — i18next format
{
"greeting": "Hello, {{name}}!",
"farewell": "Goodbye, {{name}}. See you {{day}}.",
// Pluralization: _one / _other suffixes (English)
"item_one": "{{count}} item",
"item_other": "{{count}} items",
// Context: _male / _female suffixes
"replied_male": "He replied",
"replied_female": "She replied",
"replied": "They replied",
// Nested namespace
"checkout": {
"title": "Your cart",
"empty": "Your cart is empty",
"total": "Total: {{amount}}"
}
}
// Usage with i18next (React example with react-i18next)
import { useTranslation } from 'react-i18next'
function CartSummary({ count, user }) {
const { t } = useTranslation('common')
return (
<div>
<p>{t('greeting', { name: user.name })}</p>
{/* Pluralization: pass count, i18next selects _one or _other */}
<p>{t('item', { count })}</p>
{/* Context: pass context string */}
<p>{t('replied', { context: user.gender })}</p>
</div>
)
}The _one and _other suffix convention works for English and languages with two plural forms. For languages with more plural forms (Russian, Arabic, Polish), i18next uses additional suffixes: _zero, _two, _few, _many. The library calls Intl.PluralRules internally to select the correct key — you only need to provide the right number of key variants for each language.
ICU Message Format in JSON
ICU Message Format encodes variable substitution, plural rules, gender selects, and number/date formatting inside a single JSON string value. Used by react-intl, Format.js, and @angular/localize.
// messages/en.json — ICU Message Format (react-intl / Format.js)
{
"greeting": "Hello, {name}!",
// Pluralization — all forms in one key
"cart.items": "{count, plural, one {# item} other {# items}}",
// Select — gender or arbitrary enum
"user.replied": "{gender, select, male {He replied} female {She replied} other {They replied}}",
// Number formatting
"product.price": "Price: {price, number, ::currency/USD}",
// Date formatting
"event.date": "Event on {date, date, long}",
// Nested plural + select (complex ICU)
"order.status": "{status, select, shipped {{count, plural, one {# package} other {# packages}} shipped} other {Processing}}"
}
// Usage with react-intl
import { useIntl } from 'react-intl'
function ProductCard({ price, count, gender }) {
const intl = useIntl()
return (
<div>
<p>{intl.formatMessage({ id: 'product.price' }, { price })}</p>
<p>{intl.formatMessage({ id: 'cart.items' }, { count })}</p>
</div>
)
}
// Compile ICU strings at build time with @formatjs/cli for ~10x runtime speed:
// npx formatjs compile messages/en.json --out-file compiled/en.jsonThe key advantage of ICU: one JSON key handles all plural forms for all locales. The translator fills in all plural variants inside a single string. This makes translation management tools cleaner — you have one row per message, not 6 rows for Arabic pluralization.
Pluralization Rules by Locale
CLDR (Common Locale Data Repository) defines plural categories for every language. JavaScript exposes these via Intl.PluralRules. English is simple; many languages require more forms.
// Check plural category for any number in any locale
const rules = new Intl.PluralRules('en')
console.log(rules.select(0)) // "other"
console.log(rules.select(1)) // "one"
console.log(rules.select(2)) // "other"
// Russian: 3 categories
const ruRules = new Intl.PluralRules('ru')
console.log(ruRules.select(1)) // "one" → 1 товар
console.log(ruRules.select(2)) // "few" → 2 товара
console.log(ruRules.select(5)) // "many" → 5 товаров
console.log(ruRules.select(21)) // "one" → 21 товар (loops!)
// Arabic: 6 categories — the most complex
const arRules = new Intl.PluralRules('ar')
console.log(arRules.select(0)) // "zero"
console.log(arRules.select(1)) // "one"
console.log(arRules.select(2)) // "two"
console.log(arRules.select(5)) // "few" (3-10)
console.log(arRules.select(11)) // "many" (11-99)
console.log(arRules.select(100)) // "other"// i18next: Russian plural keys in messages/ru/common.json
{
"item_one": "{{count}} товар",
"item_few": "{{count}} товара",
"item_many": "{{count}} товаров",
"item_other": "{{count}} товаров"
}
// ICU in messages/ru.json (react-intl)
{
"cart.items": "{count, plural, one {# товар} few {# товара} many {# товаров} other {# товаров}}"
}
// Arabic ICU — all 6 forms:
// "{count, plural, zero {# عناصر} one {# عنصر} two {# عنصران} few {# عناصر} many {# عنصرًا} other {# عنصر}}"
When adding a new locale, check its CLDR plural rules at cldr.unicode.org before writing translation keys. Providing the wrong number of keys causes untranslated strings or runtime errors in strict mode.
next-intl File Structure
next-intl is the standard i18n library for Next.js App Router. It reads JSON files from a messages/ directory and exposes typed translation hooks.
// Directory layout
messages/
en.json
fr.json
de.json
ar.json
// messages/en.json
{
"HomePage": {
"title": "Welcome to Jsonic",
"description": "Format, validate, and transform JSON online.",
"cta": "Open Formatter"
},
"Checkout": {
"title": "Your cart",
"items": "{count, plural, one {# item} other {# items}}",
"total": "Total: {amount}"
},
"Common": {
"submit": "Submit",
"cancel": "Cancel",
"loading": "Loading..."
}
}
// next.config.ts — enable next-intl plugin
import createNextIntlPlugin from 'next-intl/plugin'
const withNextIntl = createNextIntlPlugin()
export default withNextIntl({ /* next config */ })
// app/[locale]/page.tsx — server component
import { useTranslations } from 'next-intl'
export default function HomePage() {
const t = useTranslations('HomePage')
return (
<main>
<h1>{t('title')}</h1>
<p>{t('description')}</p>
</main>
)
}
// TypeScript: generate types from messages/en.json
// next-intl infers types automatically from the base locale file
// t('HomePage.nonexistent') → TypeScript compile errornext-intl uses the [locale] dynamic segment in the App Router. The messages/[locale].json convention keeps all translations for one locale in a single file. For large apps, use the getMessages() function with per-page imports to avoid loading all translations on every route.
Namespace Splitting for Large Projects
When a translation file exceeds a few hundred keys, split it into namespaces — one JSON file per feature or route. This enables lazy loading and smaller per-page bundles.
// Namespace split structure (i18next)
locales/
en/
common.json // shared: buttons, errors, form labels
home.json // home page only
checkout.json // checkout flow
dashboard.json // dashboard
settings.json // user settings
fr/
common.json
home.json
checkout.json
dashboard.json
settings.json
// i18next config with lazy namespace loading
import i18n from 'i18next'
import Backend from 'i18next-http-backend'
i18n.use(Backend).init({
lng: 'en',
fallbackLng: 'en',
ns: ['common'], // load 'common' on startup
defaultNS: 'common',
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
})
// Load a namespace only when needed (e.g. on checkout route)
await i18n.loadNamespaces('checkout')
const t = i18n.getFixedT('en', 'checkout')
// Shared namespace pattern: put reused strings in common.json
// Route-specific copy goes in the route namespace
// Import both in components that need shared + specific strings:
const { t: tCommon } = useTranslation('common')
const { t: tCheckout } = useTranslation('checkout')Route-based namespace splitting pairs well with Next.js dynamic imports. Load the checkout namespace only when the user navigates to /checkout. The common namespace should stay small — under 50 keys — to keep the initial bundle lean. Translation management platforms like Lokalise and Crowdin map directly to namespaces, making it easy to assign translators to specific features.
Automated Translation Workflow
Automating JSON translation with an API reduces time-to-market for new locales. The workflow: extract source strings, batch-translate via API, reassemble JSON, review, lint.
// Step 1: Flatten the source JSON for batch translation
import enMessages from './messages/en.json'
function flattenJson(obj, prefix = '') {
return Object.entries(obj).reduce((acc, [key, val]) => {
const fullKey = prefix ? `${prefix}.${key}` : key
if (typeof val === 'object' && val !== null) {
Object.assign(acc, flattenJson(val, fullKey))
} else {
acc[fullKey] = val
}
return acc
}, {})
}
const flat = flattenJson(enMessages)
// { 'HomePage.title': 'Welcome', 'Common.submit': 'Submit', ... }
// Step 2: Batch-translate with DeepL API
// DeepL preserves {{variable}} placeholders automatically
async function translateBatch(texts, targetLang) {
const res = await fetch('https://api-free.deepl.com/v2/translate', {
method: 'POST',
headers: {
'Authorization': `DeepL-Auth-Key ${process.env.DEEPL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: texts, target_lang: targetLang }),
})
const data = await res.json()
return data.translations.map(t => t.text)
}
const keys = Object.keys(flat)
const values = Object.values(flat)
const translated = await translateBatch(values, 'FR')
// Step 3: Reassemble flat keys into nested JSON
function unflattenJson(flat) {
return Object.entries(flat).reduce((acc, [key, val]) => {
const parts = key.split('.')
let cur = acc
for (let i = 0; i < parts.length - 1; i++) {
cur[parts[i]] ??= {}
cur = cur[parts[i]]
}
cur[parts[parts.length - 1]] = val
return acc
}, {})
}
const frMessages = unflattenJson(Object.fromEntries(keys.map((k, i) => [k, translated[i]])))
// Write to messages/fr.json
// Step 4: Lint — verify no keys are missing
// npx i18next-parser --config i18next-parser.config.js
// npx formatjs extract 'src/**/*.tsx' --out-file messages/en.jsonThe review step is critical: machine translation is accurate for UI strings but may miss domain-specific terms, tone, or cultural nuance. Use a human review for any text that appears in marketing-facing UI. CI linting with i18next-parser or @formatjs/cli extract catches missing or stale keys before they reach production.
Key terms defined
- Translation key
- The identifier used in source code to look up a localized string. Can be a flat dot-notation string (
"button.submit") or a path through a nested object. Keys should be stable — renaming a key requires updating all source code references and all locale files. - ICU Message Format
- A message syntax from the International Components for Unicode project. Encodes plural rules, select conditions, and number/date formatting inside a single string using
{brace syntax. Used by react-intl, Format.js, @angular/localize, and Java's MessageFormat class. The Format.js compiler converts ICU strings to an AST at build time for faster runtime evaluation. - Plural rule
- A language-specific rule that determines which plural form to use based on a number. English has two rules: "one" (exactly 1) and "other" (everything else). Plural rules are defined by CLDR and implemented in JavaScript via
Intl.PluralRules. i18next and Format.js call this API internally to select the correct translation key or ICU branch. - CLDR
- Common Locale Data Repository — a Unicode project that standardizes locale data including plural rules, date/time formats, number formats, currency symbols, and language names for hundreds of locales. CLDR is the authoritative source for how many plural forms each language has and which numbers map to which category. Available at cldr.unicode.org.
- Namespace
- A logical grouping of translation keys, typically implemented as a separate JSON file (e.g.
common.json,checkout.json). i18next loads namespaces lazily on demand, reducing initial bundle size. next-intl uses top-level object keys in a single[locale].jsonfile as namespaces, accessed viauseTranslations('Namespace'). - Interpolation
- Substituting runtime values into a translation string. i18next uses double-brace syntax:
{{name}}. ICU Message Format uses single braces:{name}. Interpolation placeholders are preserved by most machine translation APIs (DeepL, Google Translate) and must not be translated — only the surrounding text changes. - Locale identifier
- A BCP 47 language tag that identifies a language and optional region, script, or variant. Examples:
en(English),en-US(English, United States),zh-Hans-CN(Simplified Chinese, China),pt-BR(Brazilian Portuguese). Used as JSON file names (messages/en-US.json) and passed toIntlAPIs for locale-specific formatting.
Frequently asked questions
What is the best JSON format for i18n localization?
For i18next, use nested JSON with {{variable}} interpolation and _one/_other plural suffixes. For react-intl and Format.js, use flat keys with ICU Message Format strings. For next-intl, nested JSON with top-level namespace keys works best with the useTranslations hook. The i18next nested format has the broadest tooling and machine-translation support in 2026.
How do I handle pluralization in JSON i18n files?
i18next: add _one, _other (and _few, _many, _zero for complex locales) key suffixes. Pass count to t() and i18next selects the right key via Intl.PluralRules. ICU: encode all plural forms in a single key: {count, plural, one {# item} other {# items}}. ICU is more compact for languages with many plural forms like Arabic.
What is ICU Message Format in JSON?
ICU Message Format is a syntax for encoding variable substitution, plural rules, gender selects, and number/date formatting inside a single JSON string value. A plural example: {count, plural, one {# item} other {# items}}. Used by react-intl and Format.js; compiled to an AST by @formatjs/cli at build time for runtime performance.
How do I structure JSON translation files for large projects?
Split by feature namespace: common.json (shared UI), checkout.json, dashboard.json, one per route or major feature. i18next loads namespaces lazily on demand. Keep common.json under 50 keys — every user downloads it on the first page load. Route-specific namespaces are fetched only when the user navigates to that route.
How do i18next and react-intl differ in JSON format?
i18next uses {{var}} (double braces) for interpolation and key suffixes for plurals — one key per plural form. react-intl uses ICU syntax with single braces {var} and all plural forms in one key. i18next works without a build step; react-intl benefits from the @formatjs/clicompiler. Mixing both libraries in one project requires separate JSON files in each library's format.
How do I automate JSON translation with an API?
Flatten the source JSON to an array of string values, POST the array to the DeepL API (https://api-free.deepl.com/v2/translate) with the target language code, receive translated strings in order, then reassemble into a nested JSON file for the target locale. DeepL preserves {{variable}} placeholders automatically. Run i18next-parser in CI to catch missing keys.
How do I add TypeScript types to JSON translation files?
With i18next: augment the CustomTypeOptions interface in a .d.ts file using typeof en from your base locale JSON. With next-intl: the TypeScript plugin infers types from messages/en.json automatically — t('invalid.key') becomes a compile error. With react-intl: use defineMessages() with your ICU message descriptors typed against the base locale.
What are CLDR plural rules and why do they matter?
CLDR defines how many plural forms each language has and which numbers fall into each category. English has 2 forms; Russian has 3; Arabic has 6. Without correct CLDR plural rules, strings like "1 item / 2 items" are grammatically wrong in most languages. JavaScript's Intl.PluralRules API implements CLDR rules. i18next and Format.js use it internally — you only need to provide the correct number of translation strings per locale.
Further reading and primary sources
- i18next JSON format documentation — Official reference for i18next JSON v3/v4 format, interpolation, plurals, and context
- ICU Message Format syntax (Format.js) — Complete ICU syntax guide: plural, select, date, number, and nested messages
- CLDR Plural Rules chart (Unicode) — Authoritative plural category rules for every language — check before adding a new locale
- next-intl getting started — next-intl setup for Next.js App Router: messages directory, routing, and TypeScript integration
- DeepL API documentation — DeepL REST API for automated translation — batch translation, placeholder preservation, and supported languages
- JSON Schema patterns (Jsonic) — Reusable JSON Schema patterns for validating i18n translation file shapes
Validate your i18n JSON files
Paste a messages/en.json or any locale translation file into Jsonic to check structure, spot syntax errors, and format it for readability before committing.