JSON i18n: Translation Files, Pluralization, ICU Format & next-intl
Last updated:
JSON i18n translation files store locale strings as nested key-value objects — en.json and fr.json with identical key hierarchies, where each leaf value is either a plain string, an ICU message with variables ("Hello, {name}!"), or a plural form ("You have {count, plural, one {# item} other {# items}}"). ICU message format (used by next-intl, react-intl, and i18next) embeds variable interpolation and plural rules inside the JSON string value itself, eliminating the need for separate plural keys and keeping all locale-specific logic in the JSON file. This guide covers JSON translation file structure, ICU message format syntax, plural and select rules, namespace splitting for large apps, next-intl and react-i18next setup, dynamic locale loading, and automated translation workflow with i18n tooling.
JSON Translation File Structure and Key Conventions
JSON translation files use nested key-value objects to group related strings — prefer nested structures over flat for any project with more than 50 translation keys. Keys follow camelCase for multi-word identifiers, file names use locale identifiers (en.json, en-US.json, zh-CN.json), and every locale file must have an identical key hierarchy with only the string values differing.
// ── en.json — nested structure (preferred) ─────────────────────
{
"nav": {
"home": "Home",
"about": "About",
"contact": "Contact"
},
"auth": {
"login": "Log in",
"signup": "Sign up",
"logout": "Log out",
"forgotPassword": "Forgot password?"
},
"errors": {
"required": "This field is required",
"invalidEmail": "Please enter a valid email address",
"networkError": "Network error. Please try again."
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"loading": "Loading..."
}
}
// ── fr.json — identical key hierarchy, French values ───────────
{
"nav": {
"home": "Accueil",
"about": "À propos",
"contact": "Contact"
},
"auth": {
"login": "Se connecter",
"signup": "S'inscrire",
"logout": "Se déconnecter",
"forgotPassword": "Mot de passe oublié ?"
},
"errors": {
"required": "Ce champ est obligatoire",
"invalidEmail": "Veuillez saisir une adresse e-mail valide",
"networkError": "Erreur réseau. Veuillez réessayer."
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"loading": "Chargement..."
}
}
// ── Flat structure (avoid for large projects) ──────────────────
// Becomes hard to manage beyond ~50 keys
{
"navHome": "Home",
"navAbout": "About",
"authLogin": "Log in",
"errorsRequired": "This field is required"
}
// ── Key naming conventions ─────────────────────────────────────
// camelCase for multi-word keys: "loginButton", "errorMessage"
// dot-notation in code: t("auth.login")
// SCREAMING_SNAKE for system events: "PAYMENT_FAILED" (rare, prefer camelCase)
// Avoid abbreviations: "btn" → "button", "err" → "error"
// Organize by feature: auth/, dashboard/, settings/
// Or by content type: buttons/, labels/, errors/, headings/File naming conventions vary by library. next-intl uses messages/en.json (flat directory, one file per locale). react-i18next typically uses public/locales/en/common.json (locale directory, one file per namespace). For regional variants, use BCP 47 tags: en-US.json and en-GB.json can both fall back to en.json for missing keys. Keeping translation files in version control alongside source code is the standard practice — it ensures translations and code changes are reviewed and deployed together.
ICU Message Format: Variables, Plural, and Select
ICU message format embeds variable interpolation, plural rules, and select expressions inside a single JSON string value — the entire locale-specific logic lives in the translation file, not in application code. Variable interpolation uses {name} syntax, plural rules use {count, plural, ...}, and select uses {gender, select, ...} for categorical choices like gender.
// ── en.json — ICU message format examples ─────────────────────
{
// Variable interpolation — {name} replaced at runtime
"greeting": "Hello, {name}!",
"welcomeBack": "Welcome back, {firstName} {lastName}.",
// Plural rules — {count, plural, <category> {text} ...}
// # inside the text is replaced by the count value
"itemCount": "You have {count, plural, one {# item} other {# items}}",
"unreadMessages": "{count, plural, zero {No unread messages} one {1 unread message} other {# unread messages}}",
// Plural with Arabic (uses all 6 categories)
// ar.json
"fileCount": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفاً} other {# ملف}}",
// Select — {variable, select, <value> {text} ... other {text}}
// Used for gender, status, or any categorical value
"userJoined": "{gender, select, male {He joined the team} female {She joined the team} other {They joined the team}}",
// Ordinal plurals — {count, selectordinal, ...}
// #st, #nd, #rd, #th handled by CLDR ordinal rules
"rank": "You finished in {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} place",
// Nested ICU — combine select and plural
"cartSummary": "{gender, select, male {He has {count, plural, one {# item} other {# items}} in his cart} female {She has {count, plural, one {# item} other {# items}} in her cart} other {They have {count, plural, one {# item} other {# items}} in their cart}}",
// Date and number formatting (next-intl / react-intl)
"lastSeen": "Last seen {date, date, medium}",
"price": "Total: {amount, number, ::currency/USD}"
}
// ── Usage with next-intl ───────────────────────────────────────
// Server Component
import { getTranslations } from 'next-intl/server'
const t = await getTranslations('common')
t('greeting', { name: 'Alice' })
// → "Hello, Alice!"
t('itemCount', { count: 1 })
// → "You have 1 item"
t('itemCount', { count: 5 })
// → "You have 5 items"
t('userJoined', { gender: 'female' })
// → "She joined the team"
// ── ICU plural categories (CLDR) ──────────────────────────────
// zero → used for 0 in some languages (Arabic, Welsh)
// one → singular (English: 1)
// two → dual (Arabic, Hebrew: 2)
// few → small numbers (Czech: 2-4, Russian: 2-4 in certain forms)
// many → larger numbers (Polish: 5+, Arabic: 11-99)
// other → catch-all (always required as fallback)ICU format is supported natively by next-intl and react-intl. For i18next, install the i18next-icu plugin to add ICU support — by default i18next uses its own interpolation syntax ({{name}} and separate _one/_other keys). When migrating from i18next default format to ICU, use the i18next-icu plugin and update translation files to use {name} instead of {{name}} and combine plural keys into single ICU strings.
Namespace Splitting for Large Applications
Namespace splitting divides a monolithic translation JSON into multiple smaller files organized by feature or content area, reducing per-page bundle size to only the JSON actually needed. A page that uses only auth strings loads auth.json (2 KB) instead of the full translations.json (150 KB). Namespaces also enable per-team ownership and granular CDN cache invalidation.
// ── Directory structure: next-intl namespace layout ───────────
messages/
├── en/
│ ├── common.json // shared UI: buttons, labels, dates
│ ├── auth.json // login, register, password reset
│ ├── dashboard.json // dashboard-specific strings
│ ├── settings.json // user settings page
│ └── errors.json // error messages and codes
├── fr/
│ ├── common.json
│ ├── auth.json
│ ├── dashboard.json
│ ├── settings.json
│ └── errors.json
└── de/
└── ...
// ── next-intl: loading namespaces per page ─────────────────────
// app/[locale]/auth/login/page.tsx — loads only auth namespace
import { getTranslations } from 'next-intl/server'
export default async function LoginPage() {
const t = await getTranslations('auth')
const tCommon = await getTranslations('common')
return (
<form>
<h1>{t('login')}</h1>
<button type="submit">{tCommon('save')}</button>
</form>
)
}
// ── react-i18next: namespace declaration ──────────────────────
import { useTranslation } from 'react-i18next'
function LoginForm() {
// Load auth and common namespaces — fetches auth.json and common.json
const { t } = useTranslation(['auth', 'common'])
return (
<form>
<h1>{t('auth:login')}</h1>
<button type="submit">{t('common:save')}</button>
</form>
)
}
// ── Preloading critical namespaces at build time ───────────────
// next-intl: preload in layout.tsx for all pages under [locale]
// app/[locale]/layout.tsx
import { unstable_setRequestLocale } from 'next-intl/server'
export default async function LocaleLayout({ children, params: { locale } }) {
unstable_setRequestLocale(locale)
const messages = await import(`../../messages/${locale}/common.json`)
// common namespace is always available — no waterfall fetch
return <html lang={locale}>{children}</html>
}
// ── Namespace key collision prevention ────────────────────────
// BAD: same key "title" in both auth.json and dashboard.json
// Causes confusion when switching namespaces
// GOOD: prefix keys with context when ambiguous
// auth.json: { "pageTitle": "Sign In" }
// dashboard.json: { "pageTitle": "Dashboard" }
// Or use distinct keys:
// auth.json: { "loginTitle": "Sign In" }
// dashboard.json: { "dashboardTitle": "Dashboard" }A typical production app organizes namespaces as: common (buttons, labels, navigation — loaded on every page), auth (authentication flow), errors (error messages referenced across the app), and one namespace per major feature (dashboard, settings, checkout). Loading common globally and feature namespaces per-page keeps per-page translation payload between 5–20 KB, versus 50–200 KB for a monolithic file. See the JSON config management guide for related patterns on splitting large JSON files.
next-intl Setup: App Router JSON i18n
next-intl is the standard i18n library for Next.js App Router — it integrates with Server Components, supports ICU message format, handles locale routing middleware, and requires zero client-side JavaScript for statically rendered pages. Setup involves four files: routing config, request config, middleware, and layout integration.
// 1. Install
// npm install next-intl
// 2. messages/en.json
{
"nav": { "home": "Home", "about": "About" },
"auth": { "login": "Log in", "greeting": "Hello, {name}!" }
}
// 3. i18n/routing.ts — supported locales and default
import { defineRouting } from 'next-intl/routing'
export const routing = defineRouting({
locales: ['en', 'fr', 'de', 'zh'],
defaultLocale: 'en',
// optional: localePrefix strategy
localePrefix: 'always', // /en/page, /fr/page
})
// 4. i18n/request.ts — load JSON messages per request
import { getRequestConfig } from 'next-intl/server'
import { routing } from './routing'
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale
// Validate locale — fall back to default if invalid
if (!locale || !routing.locales.includes(locale as typeof routing.locales[number])) {
locale = routing.defaultLocale
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
}
})
// 5. middleware.ts — locale detection and URL routing
import createMiddleware from 'next-intl/middleware'
import { routing } from './i18n/routing'
export default createMiddleware(routing)
export const config = {
matcher: ['/((?!api|_next|_vercel|.*\..*).*)'],
}
// 6. app/[locale]/layout.tsx — provide messages to the tree
import { NextIntlClientProvider } from 'next-intl'
import { getMessages } from 'next-intl/server'
export default async function LocaleLayout({ children, params: { locale } }) {
const messages = await getMessages()
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
)
}
// 7. Server Component — getTranslations() (async, type-safe)
import { getTranslations } from 'next-intl/server'
export default async function AuthPage() {
const t = await getTranslations('auth')
return (
<div>
<h1>{t('login')}</h1>
<p>{t('greeting', { name: 'Alice' })}</p>
</div>
)
}
// 8. Client Component — useTranslations() hook
'use client'
import { useTranslations } from 'next-intl'
export function NavBar() {
const t = useTranslations('nav')
return <nav><a href="/">{t('home')}</a></nav>
}
// 9. generateStaticParams — pre-render all locales at build time
export function generateStaticParams() {
return routing.locales.map(locale => ({ locale }))
}next-intl's getTranslations() in Server Components runs at request time and never ships translation JSON to the client bundle — the translated strings are rendered to HTML on the server. For Client Components, NextIntlClientProvider passes only the messages for the current page (not all locales) via React context, keeping client bundle size minimal. The locale detection middleware reads the Accept-Language header and any locale cookie, then redirects to the appropriate locale-prefixed URL.
react-i18next: JSON Translation in React
react-i18next is the most widely used i18n library for React SPAs and non-App-Router Next.js projects. It loads translation JSON files via HTTP (i18next-http-backend), auto-detects the user's locale (i18next-browser-languagedetector), and provides the useTranslation hook and Trans component for JSX interpolation.
// 1. Install
// npm install react-i18next i18next i18next-http-backend i18next-browser-languagedetector
// 2. public/locales/en/common.json
{
"nav": { "home": "Home" },
"greeting": "Hello, {{name}}!",
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items"
}
// 3. i18n.ts — i18next initialization
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import HttpBackend from 'i18next-http-backend'
import LanguageDetector from 'i18next-browser-languagedetector'
i18n
.use(HttpBackend) // load JSON from /public/locales/
.use(LanguageDetector) // detect locale from browser
.use(initReactI18next)
.init({
fallbackLng: 'en',
defaultNS: 'common',
ns: ['common', 'auth', 'dashboard'],
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
detection: {
order: ['cookie', 'localStorage', 'navigator'],
caches: ['cookie'],
},
interpolation: {
escapeValue: false, // React already escapes values
},
// Missing key handler — log for monitoring
missingKeyHandler: (lngs, ns, key) => {
console.warn(`[i18n] Missing key: ${ns}:${key} for ${lngs.join(', ')}`)
},
})
export default i18n
// 4. useTranslation hook — basic usage
import { useTranslation } from 'react-i18next'
function LoginPage() {
const { t, i18n } = useTranslation('auth')
return (
<div>
<h1>{t('login')}</h1>
<p>{t('greeting', { name: 'Alice' })}</p>
{/* Plural — i18next uses count variable automatically */}
<p>{t('itemCount', { count: 5 })}</p>
{/* Switch locale */}
<button onClick={() => i18n.changeLanguage('fr')}>Français</button>
</div>
)
}
// 5. Trans component — JSX interpolation (links, bold, etc.)
import { Trans } from 'react-i18next'
// en/common.json: { "privacyNotice": "By signing up, you agree to our <1>Privacy Policy</1>." }
function SignupFooter() {
return (
<Trans i18nKey="privacyNotice" ns="common">
By signing up, you agree to our <a href="/privacy">Privacy Policy</a>.
</Trans>
)
}
// 6. Namespace switching in one component
function Dashboard() {
const { t: tCommon } = useTranslation('common')
const { t: tDash } = useTranslation('dashboard')
return (
<div>
<h1>{tDash('title')}</h1>
<button>{tCommon('save')}</button>
</div>
)
}i18next's default plural syntax uses separate keys with _one and _other suffixes (e.g., itemCount_one, itemCount_other), which works but scatters related strings. Install i18next-icu to switch to unified ICU format ("itemCount": "{count, plural, one {# item} other {# items}}") — matching the format used by next-intl and react-intl. See the React Query JSON fetching guide for patterns on combining data fetching with translation loading.
Dynamic Locale Loading and CDN Delivery
Dynamic locale loading fetches translation JSON files on demand rather than bundling them into the initial JavaScript payload — critical for apps supporting many languages where bundling all locales would add megabytes to the initial load. CDN delivery with proper cache headers reduces translation JSON load time from 200–500 ms to 5–20 ms for repeat visitors.
// ── i18next-http-backend: load translation JSON from CDN ──────
import HttpBackend from 'i18next-http-backend'
i18n.use(HttpBackend).init({
backend: {
// CDN URL — use versioned path for cache busting
loadPath: 'https://cdn.example.com/locales/v2/{{lng}}/{{ns}}.json',
// Request options — add Cache-Control headers
requestOptions: {
mode: 'cors',
credentials: 'omit',
cache: 'default', // use browser cache
},
// Custom load function — add auth token if locale endpoint is protected
customHeaders: () => ({
'Accept-Encoding': 'gzip, deflate, br',
}),
},
// Retry count for failed fetches
load: 'languageOnly', // load 'en' not 'en-US' (unless en-US.json exists)
})
// ── Cache-Control for translation JSON files ──────────────────
// nginx config — 1 week cache for versioned locale files
server {
location ~* /locales/v[0-9]+/ {
add_header Cache-Control "public, max-age=604800, immutable";
add_header Vary "Accept-Encoding";
gzip_static on; // serve pre-compressed .json.gz if available
}
}
// Vercel headers config (vercel.json)
{
"headers": [
{
"source": "/locales/:version/:locale/:namespace.json",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=604800, s-maxage=604800" },
{ "key": "Vary", "value": "Accept-Encoding" }
]
}
]
}
// ── Cache busting strategy ─────────────────────────────────────
// Option 1: Version in path — /locales/v2/en/common.json
// Update version number in loadPath when translations change
// Option 2: Hash in filename — /locales/en/common.abc123.json
// Generated at build time, update import references
// Option 3: Query string — /locales/en/common.json?v=abc123
// Simpler but CDNs may not cache URLs with query strings
// ── Gzip compression sizes (typical translation JSON) ─────────
// en/common.json: 12 KB → 3 KB gzipped (75% reduction)
// en/dashboard.json: 45 KB → 11 KB gzipped (76% reduction)
// All locales flat: 800 KB → 180 KB gzipped (77% reduction)
// ── Prefetching the next locale before language switch ─────────
async function switchLocale(newLocale: string) {
// Pre-fetch all loaded namespaces for the new locale
await i18n.loadLanguages(newLocale)
// Switch — no loading state since JSON is already cached
await i18n.changeLanguage(newLocale)
}
// ── next-intl: server-side locale loading (no client fetch) ───
// In App Router, messages are loaded server-side and streamed to the client
// No runtime HTTP request — translation JSON is read from the filesystem
// at request time (or statically inlined at build time for SSG)
import { getMessages } from 'next-intl/server'
const messages = await getMessages() // reads messages/en.json from diskFor apps using next-intl with App Router, locale JSON is loaded server-side (filesystem read) and the translated HTML is streamed to the browser — no client-side JSON fetch at all for Server Component pages. Dynamic locale switching (without a full page navigation) still requires client-side JSON loading; pre-fetch the new locale JSON before switching to avoid a loading flash. Gzip compression reduces typical translation JSON by 70–77%; use Content-Encoding: br (Brotli) for an additional 10–15% reduction over gzip.
Translation Workflow: Extraction, Review, and Automation
A production translation workflow automates three tasks: extracting translation keys from source code into JSON files (so developers never manually edit translation files), detecting missing or unused keys in CI, and integrating with translation management systems (TMS) like Crowdin or Lokalise for translator review. Key extraction with i18next-parser eliminates the most common i18n bug — a key exists in code but not in the JSON file.
// ── i18next-parser: extract keys from JSX source ──────────────
// npm install -D i18next-parser
// i18next-parser.config.js
module.exports = {
// Glob patterns for source files to scan
input: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}'],
// Output pattern — {{lng}} = locale, {{ns}} = namespace
output: 'public/locales/{{lng}}/{{ns}}.json',
// Source language (generates/updates the source JSON)
defaultLocales: ['en'],
// All target locales — creates empty keys for new strings
locales: ['en', 'fr', 'de', 'zh'],
// Namespace separator in t() calls
namespaceSeparator: ':', // t('auth:login')
keySeparator: '.', // nested keys: t('nav.home')
// Default value for new keys (helps translators understand context)
defaultValue: '__STRING_NOT_TRANSLATED__',
// Suffix for keys that exist in JSON but not in source (not yet deleted)
keepRemoved: false, // remove unused keys automatically
// Plural separator for non-ICU mode
pluralSeparator: '_',
}
// Run extraction:
// npx i18next-parser
// ── Before extraction ──────────────────────────────────────────
// src/components/LoginForm.tsx
import { useTranslation } from 'react-i18next'
function LoginForm() {
const { t } = useTranslation('auth')
return (
<form>
<h1>{t('title')}</h1>
<label>{t('emailLabel')}</label>
<button>{t('submitButton')}</button>
</form>
)
}
// ── After extraction — public/locales/en/auth.json ────────────
{
"title": "Sign In", // existing key preserved
"emailLabel": "Email", // existing key preserved
"submitButton": "Log In", // existing key preserved
"newKey": "__STRING_NOT_TRANSLATED__" // newly detected key
}
// ── CI check: fail on missing translations ─────────────────────
// package.json
{
"scripts": {
"i18n:extract": "i18next-parser",
"i18n:check": "node scripts/check-translations.mjs"
}
}
// scripts/check-translations.mjs
import { readFileSync, readdirSync } from 'fs'
import { join } from 'path'
const localesDir = 'public/locales'
const sourceLocale = 'en'
const namespaces = readdirSync(join(localesDir, sourceLocale))
.filter(f => f.endsWith('.json'))
let hasErrors = false
for (const ns of namespaces) {
const sourceKeys = Object.keys(
JSON.parse(readFileSync(join(localesDir, sourceLocale, ns), 'utf8'))
)
for (const locale of readdirSync(localesDir).filter(l => l !== sourceLocale)) {
const targetPath = join(localesDir, locale, ns)
const targetKeys = Object.keys(JSON.parse(readFileSync(targetPath, 'utf8')))
const missing = sourceKeys.filter(k => !targetKeys.includes(k))
if (missing.length) {
console.error(`Missing in ${locale}/${ns}: ${missing.join(', ')}`)
hasErrors = true
}
}
}
if (hasErrors) process.exit(1)
// ── Crowdin / Lokalise integration ────────────────────────────
// crowdin.yml — sync JSON files to/from Crowdin TMS
project_id: 123456
api_token_env: CROWDIN_PERSONAL_TOKEN
files:
- source: /public/locales/en/**/*.json
translation: /public/locales/%locale%/**/%original_file_name%
type: i18next_multivalue_jsonTranslation memory (TM) in Crowdin and Lokalise stores previously translated segments — when a new English string is identical or similar to a previously translated string, the TM suggests the past translation automatically, reducing translator effort by 30–60% for iterative UI updates. Configure TM reuse threshold (typically 75–100% match) to balance quality and automation. For automated machine translation of new keys, both Crowdin and Lokalise integrate with DeepL, Google Translate, and other MT engines that accept JSON format directly.
Key Terms
- ICU message format
- A string syntax standardized by the Unicode Consortium (International Components for Unicode) that embeds variable interpolation, plural rules, select expressions, and number/date formatting inside a single JSON string value. ICU uses
{variable}for simple interpolation,{count, plural, one {# item} other {# items}}for plural rules, and{gender, select, male {He} female {She} other {They}}for categorical selection. Supported natively by next-intl and react-intl; available in i18next via thei18next-icuplugin. ICU format keeps all locale-specific logic — including plural forms for languages with 2–6 plural categories — inside the JSON translation file rather than in application code. - namespace
- A named subset of translation keys stored in a separate JSON file, loaded independently from other namespaces. In react-i18next, namespaces correspond to files in the locale directory:
auth.jsonis theauthnamespace, accessed viauseTranslation('auth'). In next-intl, namespaces correspond to top-level keys within a single locale file or to separate files depending on the project structure. Namespaces enable on-demand loading (only fetch the JSON a given page needs), per-team ownership of translation files, and granular cache invalidation. The default namespace in both libraries iscommon— translations without an explicit namespace prefix are looked up incommon. - fallback locale
- The locale whose translation JSON is used when a key is missing from the requested locale's JSON file. Configured as
fallbackLng: 'en'in i18next or implicitly viadefaultLocale: 'en'in next-intl. When the French translation JSON is missing a key that exists in English, the library returns the English string silently rather than showing the raw key name or an empty string. A robust fallback chain might be:zh-TW→zh→en, so Traditional Chinese falls back to Simplified Chinese before falling back to English. Log all fallback events to your monitoring system to identify keys that need translation. - locale detection
- The process of determining which locale to use for a given user or request. Browser-side detection (used by i18next-browser-languagedetector) checks sources in priority order: URL path (
/fr/dashboard), cookie (i18next=fr),localStorage,navigator.language(browser language setting), andAccept-LanguageHTTP header. Server-side detection (used by next-intl middleware) reads theAccept-Languageheader and any locale cookie, then redirects to the appropriate locale-prefixed URL. The detected locale is matched against the configured supported locales list; unrecognized locales fall back to the default locale. Locale detection order matters — URL path takes precedence over browser preference so users can share locale-specific URLs. - plural form
- A language-specific grammatical form of a word or phrase that varies based on quantity. English has two plural forms: singular (1 item) and plural (0 items, 2 items, 5 items). Many languages have more: Russian and Arabic have up to 6 plural categories defined by the Unicode Common Locale Data Repository (CLDR). In ICU message format, plural forms are specified inside
{count, plural, ...}blocks using CLDR category names:zero,one,two,few,many,other. Theothercategory is required as the catch-all fallback in all languages. i18next default format uses separate JSON keys with_one/_othersuffixes; ICU format combines all plural forms into a single JSON value. - translation memory
- A database of previously translated string pairs (source language segment → target language segment) stored as structured data (often JSON or TMX format) in a Translation Management System. When a new source string is identical or similar to a string in the TM, the TMS suggests the past translation automatically — 100% matches are reused without translator review, and fuzzy matches (75–99% similar) are flagged for human verification. TM reduces translation cost and time for iterative UI changes where strings are frequently updated or reused across screens. Crowdin, Lokalise, and Phrase all maintain per-project TM databases and apply them automatically during the translation workflow. For JSON i18n files, TM operates at the value level — each JSON string value is one TM segment.
FAQ
What is the standard structure of a JSON i18n translation file?
A standard JSON i18n translation file is a nested key-value object where keys are message identifiers and values are plain strings or ICU message strings. The preferred structure uses nested objects for grouping: "nav": { "home": "Home" } rather than flat keys like navHome. File names follow locale identifiers: en.json for English, fr.json for French, zh-CN.json for Simplified Chinese. Keys use camelCase for multi-word identifiers (loginButton, errorMessage) and must match exactly across all locale files — every locale JSON must have the same key hierarchy with only the string values differing. Flat structures work for small projects (under ~50 keys) but nested structures by feature (auth, nav, dashboard) or content type (buttons, labels, errors) are preferred for maintainability. Organize files as one JSON per locale for small apps, or one JSON per namespace per locale for large apps.
How do I handle pluralization in JSON translation files?
Use ICU message format plural rules embedded directly in the JSON string value: "itemCount": "You have {count, plural, one {# item} other {# items}}". The {count, plural, ...} syntax selects the correct plural form based on the count value, with # replaced by the actual number. For languages with more plural categories (Russian has 4, Arabic has 6), add the additional CLDR categories: zero, one, two, few, many, other. Always include other as it is the required fallback. The alternative approach — separate keys with _one/_other suffixes — is the i18next default format and works, but ICU format in a single value is preferred for new projects as it keeps all plural forms in one JSON entry and is supported natively by next-intl and react-intl. Use i18next with the i18next-icu plugin to get ICU format in i18next projects.
What is ICU message format and how does it work with JSON?
ICU message format is a Unicode standard for embedding variable interpolation, plural rules, and select expressions inside a single string. In JSON translation files, an ICU message is stored as a normal JSON string value: "greeting": "Hello, {name}!". The runtime library (next-intl, react-intl, or i18next with i18next-icu) parses the ICU syntax and replaces the placeholders. Variable interpolation: {name} is replaced by the provided value. Plural rules: {count, plural, one {# item} other {# items}} selects the form based on count and replaces # with the value. Select: {gender, select, male {He} female {She} other {They}} selects based on a string value. ICU expressions can be nested — a select inside a plural — for complex sentences. The JSON file stays valid standard JSON since the ICU syntax is just a string value; no special JSON extensions are needed.
How do I set up next-intl with JSON translation files in Next.js?
Install next-intl: npm install next-intl. Create messages/en.json with your translation strings. Create i18n/routing.ts using defineRouting to declare supported locales and the default locale. Create i18n/request.ts using getRequestConfig to load the correct JSON file: return { locale, messages: (await import(`../messages/${locale}.json`)).default }. Create middleware.ts using createMiddleware(routing) for locale detection and URL routing. Restructure your app directory to app/[locale]/ so routes are locale-prefixed. Wrap your root layout with NextIntlClientProvider passing messages from getMessages(). In Server Components, use const t = await getTranslations('namespace'). In Client Components, use const t = useTranslations('namespace'). Generate static paths for all locales with generateStaticParams returning the locales array.
How do I split large JSON translation files into namespaces?
Namespaces split a single large translation JSON into multiple smaller files organized by feature or page. Create a directory per locale with one JSON per namespace: messages/en/common.json, messages/en/auth.json, messages/en/dashboard.json. In next-intl, load a specific namespace: const t = await getTranslations('auth') reads only auth.json. In react-i18next, declare namespaces in useTranslation: const { t } = useTranslation(["common", "auth"]). Load common globally (in root layout) since it is used on every page; load feature namespaces per-page to keep per-page translation payload to 5–20 KB. Preload critical namespaces at build time for SSG. Prevent key collisions by using consistent, descriptive names and never reusing key names across namespaces. Configure i18next with ns: ['common', 'auth', 'dashboard'] and defaultNS: 'common'.
How do I handle missing translation keys in JSON i18n?
Configure a fallback locale so missing keys silently return the fallback language string instead of the raw key. In next-intl, defaultLocale: 'en' in defineRouting acts as the fallback. In i18next, set fallbackLng: 'en' in init options. Enable missing key detection in development: in i18next, missingKeyHandler: (lngs, ns, key) => console.warn(...) logs missing keys. For CI, run a diff script that compares the key sets of each locale JSON against the source (English) JSON and fails the build if any locale is missing keys. Never show raw key strings (like auth.loginButton) to end users — configure a parseMissingKeyHandler that returns the key in a readable format or the English fallback. Track fallback events in your monitoring system (Datadog, Sentry) so translators are notified of missing keys promptly.
How do I lazy-load JSON translation files to reduce bundle size?
With react-i18next and i18next-http-backend, configure backend: { loadPath: "/locales/{{lng}}/{{ns}}.json" } — the browser fetches the JSON file via HTTP only when first needed. Host files in /public/locales/ for Next.js, or a CDN for production. Set Cache-Control: public, max-age=604800 (1 week) so browsers cache translation JSON between visits; use versioned paths or query strings for cache busting when translations change. Gzip compression reduces translation JSON by 70–77%; Brotli adds another 10–15%. For locale switching without a page reload, call i18n.loadLanguages(newLocale) to pre-fetch before i18n.changeLanguage(newLocale) to avoid a loading flash. With next-intl in App Router, translation JSON is loaded server-side at request time and never fetched client-side for Server Component pages — the most efficient approach for SEO and performance.
How do I automate translation key extraction from React components?
Use i18next-parser: install with npm install -D i18next-parser, create i18next-parser.config.js with input glob patterns (src/**/*.tsx) and output path (public/locales/{{lng}}/{{ns}}.json), then run npx i18next-parser. The parser scans all t('key'), useTranslation, and Trans calls and adds missing keys to JSON files (with a configurable default value) and removes keys no longer present in source code. Add an npm script "i18n:extract": "i18next-parser" and run it before committing. For CI, use a check script that diffs source and locale JSON key sets and exits with a non-zero code if keys are missing. Both Crowdin and Lokalise accept standard nested or flat JSON format for translation management — configure export to match your namespace structure. Translation memory in these platforms automatically suggests past translations for identical or similar new strings, reducing translation effort by 30–60% for iterative updates.
Further reading and primary sources
- next-intl Documentation — Official next-intl docs for Next.js App Router i18n with JSON translation files and ICU message format
- ICU Message Format Syntax — Unicode ICU documentation for message format syntax including plurals, select, and number formatting
- react-i18next Documentation — react-i18next docs covering useTranslation, Trans component, namespace splitting, and backend plugins
- CLDR Plural Rules — Unicode CLDR plural category rules for all languages — zero, one, two, few, many, other definitions
- i18next-parser on GitHub — Automated translation key extraction from JavaScript/TypeScript/JSX source files into JSON translation files