# JSON Translation Files Explained: A Beginner's Guide to Localization

> Learn what JSON translation files are, how the t() function works, why developers use en.json and fr.json, and how to organize multilingual applications the right way.
- **Author**: Palak Phakatkar
- **Published**: 2026-07-28
- **URL**: https://www.babelize.co/blogs/json-translation-files-explained

---

<p class="lead">
    A JSON translation file is where your app's words live. Learn how JSON locale files are structured, how the <code>t()</code> function finds the right string, how to name and organize files per language, how to translate a JSON file without breaking it, and the mistakes that cause missing text in production.
</p>

<h2>What Is a JSON Translation File?</h2>

<p>
    A JSON translation file (also called a JSON locale file or JSON language file) stores the user-facing text of your application for one language. Instead of writing text directly inside your components, you give every string a <strong>key</strong> and put the actual words in a file per language. At runtime, your i18n library loads the file for the active locale and looks up each key.
</p>

<p>Think of it like a dictionary:</p>

<pre><code>{
    "welcome": "Welcome",
    "login": "Log in",
    "logout": "Log out",
    "profile": "Profile"
}</code></pre>

<p>
    Here, <code>welcome</code>, <code>login</code>, <code>logout</code>, and <code>profile</code> are <strong>translation keys</strong>. The values are the text users see. A French file has the exact same keys with French values, and a German file has the same keys with German values. That is the whole contract of a multi-language JSON setup: <strong>keys are identical across every locale, only the values change.</strong>
</p>

<p>
    JSON became the default format for web localization because every JavaScript runtime can parse it natively, bundlers can import it directly, and it maps cleanly to the nested objects that libraries like next-intl, react-i18next, vue-i18n, and FormatJS expect.
</p>

<h2>Why Use JSON Instead of Hardcoding Text?</h2>

<p>
    On a small project, writing text directly inside your components feels normal. The moment someone asks to support French, then German, then Japanese, every hardcoded string becomes a file you have to edit, a review you have to repeat, and a place a translation can be forgotten.
</p>

<p>Instead, your component asks for a key:</p>

<pre><code>&lt;h1&gt;{t("welcome")}&lt;/h1&gt;</code></pre>

<p>
    Now the component does not care which language the user speaks. The translation file does the rest. This separation is the core of internationalization; if the distinction between preparing code and adapting content is new to you, our explainer on <a href="/blogs/i18n-vs-l10n/">i18n vs l10n</a> covers it in detail.
</p>

<ul>
    <li>Adding a language means adding a file, not editing components</li>
    <li>Translators can work on text without touching the codebase</li>
    <li>Copy changes go through a single, reviewable place</li>
    <li>Tools can diff, validate, and machine-check the files in CI</li>
</ul>

<h2>Flat vs Nested Keys</h2>

<p>There are two common ways to shape a JSON translation file. A <strong>flat</strong> file uses one level of keys, usually with dots in the key name:</p>

<pre><code>{
    "auth.login.title": "Sign in to your account",
    "auth.login.button": "Log in",
    "auth.logout": "Log out"
}</code></pre>

<p>A <strong>nested</strong> file groups related strings into objects:</p>

<pre><code>{
    "auth": {
        "login": {
            "title": "Sign in to your account",
            "button": "Log in"
        },
        "logout": "Log out"
    }
}</code></pre>

<p>
    In most libraries you read both shapes the same way, with dot notation: <code>t("auth.login.button")</code>. Nested files are easier to scan and let you pass a whole group to one component. next-intl, for example, expects nested messages and lets you scope a hook to a branch with <code>useTranslations("auth.login")</code>, then call <code>t("button")</code>. Flat files are easier to grep and diff line by line, and some translation tools prefer them. Pick one shape per project and stick with it; mixing them leads to keys that resolve in one locale and not another.
</p>

<figure class="blog-figure">
    <img src="/blog/inline/json-translation-files-explained-1.webp" alt="Side-by-side diagram of the same strings as flat keys (&quot;auth.login.title&quot;, &quot;auth.login.button&quot;, &quot;auth.logout&quot;) and as nested objects (auth &gt; login &gt; title and button, auth &gt; logout). Both resolve t(&quot;auth.login.button&quot;) to &quot;Log in&quot;; next-intl can scope a nested branch with useTranslations(&quot;auth.login&quot;) and then call t(&quot;button&quot;)." width="1500" height="960" loading="lazy" decoding="async" />
    <figcaption>Flat and nested files hold the same strings and resolve the same dot-notation key.</figcaption>
</figure>

<p>
    Whichever shape you choose, <strong>name keys by meaning and location, not by content.</strong> <code>checkout.payButton</code> survives a copy change from "Pay now" to "Complete purchase". A key called <code>payNow</code> does not, and a key called <code>text1</code> tells nobody anything.
</p>

<h2>Naming and Organizing Locale Files</h2>

<p>
    Name each file after a BCP 47 language tag: <code>en.json</code>, <code>fr.json</code>, <code>de.json</code>, <code>ja.json</code>, and add a region only when content actually differs, as in <code>pt-BR.json</code> and <code>pt-PT.json</code>. Use hyphens rather than underscores, since the JavaScript Intl API and HTML <code>lang</code> attributes expect hyphenated tags. Our guide to <a href="/blogs/locale-codes-bcp-47-explained/">locale codes and BCP 47</a> covers the traps, such as Japanese being <code>ja</code>, not <code>jp</code>.
</p>

<p>A single file per locale is fine for small apps. As the app grows, split each locale into a folder of <strong>namespaces</strong>, one per feature:</p>

<pre><code>locales/
├── en/
│   ├── common.json
│   ├── auth.json
│   └── billing.json
├── fr/
│   ├── common.json
│   ├── auth.json
│   └── billing.json
└── pt-BR/
    ├── common.json
    ├── auth.json
    └── billing.json</code></pre>

<p>
    Namespaces keep files small enough to review, reduce merge conflicts when several people add strings at once, and let a library load only the namespaces a page needs instead of every string in the app.
</p>

<figure class="blog-figure">
    <img src="/blog/inline/json-translation-files-explained-2.webp" alt="Diagram of locale file layouts. Small app: a messages folder with one file per BCP 47 locale: en.json, fr.json, de.json, ja.json, pt-BR.json and pt-PT.json. Growing app: a locales folder with en, fr and pt-BR subfolders, each holding common.json, auth.json and billing.json namespaces, for smaller reviews, fewer merge conflicts and loading only the namespaces a page needs." width="1500" height="960" loading="lazy" decoding="async" />
    <figcaption>Start with one file per locale; split each locale into feature namespaces as the app grows.</figcaption>
</figure>

<h2>How t() Finds a Translation</h2>

<p>
    The <code>t()</code> function (short for "translate") does not translate anything. It is a lookup. Given a key, it resolves the value in the active locale's messages, fills in any variables, and returns a string. Here is a minimal version in plain JavaScript that shows the moving parts:
</p>

<pre><code>const messages = {
    en: { cart: { title: "Your cart", greeting: "Hi {name}!" } },
    fr: { cart: { title: "Votre panier" } },
};

function t(locale, key, values = {}) {
    const lookup = (loc) =&gt; key.split(".").reduce((node, part) =&gt; node?.[part], messages[loc]);
    const template = lookup(locale) ?? lookup("en") ?? key;
    return template.replace(/\{(\w+)\}/g, (_, name) =&gt; values[name] ?? `{${name}}`);
}

t("fr", "cart.title");                      // "Votre panier"
t("fr", "cart.greeting", { name: "Léa" });  // "Hi Léa!" (fell back to English)
t("fr", "cart.total");                      // "cart.total" (missing everywhere)</code></pre>

<p>Real libraries add caching, plural rules, and formatting, but follow the same three steps:</p>

<ol>
    <li><strong>Resolve the key</strong> by walking the dot path through the active locale's messages.</li>
    <li><strong>Fall back</strong> when it is missing. i18next walks its <code>fallbackLng</code> chain (for example <code>pt-BR</code> -> <code>pt</code> -> <code>en</code>). next-intl reports the missing message through its <code>onError</code> handler and renders a fallback, by default the key path.</li>
    <li><strong>Interpolate</strong> variables into the string.</li>
</ol>

<p>
    That fallback behavior is why a missing key rarely crashes your app. Instead, a French user quietly sees English, or worse, sees <code>cart.total</code> on screen. Treat missing keys as bugs and catch them before users do: our guide to <a href="/blogs/automate-app-localization-ai-react-github/">automating app localization</a> includes a short Node.js script that fails CI when a locale is missing keys.
</p>

<h2>Placeholders: {name} vs {{name}}</h2>

<p>
    Variables are where JSON translation files stop being interchangeable between libraries. The two dominant syntaxes look almost identical:
</p>

<pre><code>// ICU syntax: next-intl, react-intl / FormatJS, vue-i18n
"greeting": "Welcome back, {name}!"

// i18next default syntax: i18next, react-i18next
"greeting": "Welcome back, {{name}}!"</code></pre>

<p>
    Both are called the same way, <code>t("greeting", { name: user.firstName })</code>, but a file written for one library will print literal braces in the other. If you migrate between libraries, convert placeholders as part of the move.
</p>

<p>
    Always use placeholders rather than concatenating fragments. <code>t("hello") + " " + name</code> forces English word order on every language. A single message with <code>{name}</code> lets the translator put the name wherever the grammar needs it.
</p>

<h2>Plurals in JSON Translation Files</h2>

<p>Plurals are the other big syntax split. ICU-based libraries keep every plural form inside one message:</p>

<pre><code>{
    "itemCount": "{count, plural, =0 {Your cart is empty} one {# item} other {# items}}"
}</code></pre>

<p>i18next (v21 and later) uses separate keys with a plural-category suffix, and picks one based on the <code>count</code> you pass:</p>

<pre><code>{
    "itemCount_zero": "Your cart is empty",
    "itemCount_one": "{{count}} item",
    "itemCount_other": "{{count}} items"
}</code></pre>

<p>
    Either way, the set of plural forms belongs to each language, not to English. Polish needs <code>one</code>, <code>few</code>, <code>many</code>, and <code>other</code>; Japanese needs only <code>other</code>. A Polish i18next file therefore has keys like <code>itemCount_few</code> that the English file does not. Our <a href="/blogs/icu-messageformat-plurals-guide/">ICU MessageFormat plurals guide</a> explains the categories and why a <code>count === 1</code> check breaks in most languages.
</p>

<h2>Loading JSON Files in Real Libraries</h2>

<h3>next-intl (Next.js App Router)</h3>

<p>
    next-intl loads one messages file per locale on the server, in the request config. Only the locale being rendered is imported:
</p>

<pre><code>// src/i18n/request.ts
import { getRequestConfig } from "next-intl/server";
import { hasLocale } from "next-intl";
import { routing } from "./routing";

export default getRequestConfig(async ({ requestLocale }) =&gt; {
    const requested = await requestLocale;
    const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;

    return {
        locale,
        messages: (await import(`../../messages/${locale}.json`)).default,
    };
});</code></pre>

<p>
    Server Components then call <code>getTranslations()</code> and Client Components call <code>useTranslations()</code>. The full setup is in our <a href="/blogs/implement-i18n-nextjs-next-intl/">step-by-step next-intl tutorial</a>.
</p>

<h3>react-i18next</h3>

<p>react-i18next takes resources organized by language and namespace. You can bundle them directly, or lazy-load them over HTTP with a backend plugin such as <code>i18next-http-backend</code>:</p>

<pre><code>import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import enCommon from "./locales/en/common.json";
import frCommon from "./locales/fr/common.json";

i18n.use(initReactI18next).init({
    resources: {
        en: { common: enCommon },
        fr: { common: frCommon },
    },
    lng: "en",
    fallbackLng: "en",
    defaultNS: "common",
    interpolation: { escapeValue: false }, // React already escapes output
});</code></pre>

<p>
    Components use <code>const { t } = useTranslation("common")</code>. If you are still choosing between the two libraries, see our <a href="/blogs/next-intl-vs-react-i18next/">next-intl vs react-i18next comparison</a>.
</p>

<h2>How to Translate a JSON File Safely</h2>

<p>
    Translating a JSON file looks like a simple find-and-replace job. It is the step where most localization bugs are introduced, because a translator or a machine translation tool that does not understand the file's rules can break it in ways that only show up at runtime.
</p>

<ul>
    <li><strong>Translate values, never keys.</strong> <code>"login": "Connexion"</code> is right. <code>"connexion": "Connexion"</code> means the French build can no longer find <code>login</code>.</li>
    <li><strong>Preserve placeholders exactly.</strong> <code>{name}</code> and <code>{{count}}</code> must survive untouched. A translated <code>{nom}</code> renders as a literal or throws.</li>
    <li><strong>Keep ICU syntax intact.</strong> Keywords like <code>plural</code>, <code>select</code>, <code>one</code>, and <code>other</code> are code, not English. Only the text inside the branches gets translated.</li>
    <li><strong>Keep rich-text tags.</strong> If a message contains <code>&lt;link&gt;Terms&lt;/link&gt;</code>, the tag names must stay the same so your component can map them.</li>
    <li><strong>Escape quotes and save as UTF-8.</strong> A French value containing a straight double quote must be written as <code>\"</code>. Accented and non-Latin characters can be stored directly in UTF-8; there is no need for <code>\u</code> escapes.</li>
    <li><strong>Validate the result.</strong> Parse every file after translation. One missing comma makes the entire locale fail to load.</li>
</ul>

<p>
    Generic machine translation, including pasting a whole file into a chatbot, tends to fail on exactly these points: it translates keys, rewrites placeholders, "fixes" ICU keywords, or returns a file with a trailing comma. It also lacks context; "Open" as a button and "Open" as a ticket status translate differently in many languages. Whatever tool you use, run the same automated checks on its output that you would run on a human translator's.
</p>

<h2>JSON vs Other Translation File Formats</h2>

<p>JSON dominates web projects, but it is not the only format. You will meet these as soon as you add a mobile app or work with a translation agency:</p>

<table>
    <thead>
        <tr>
            <th>Format</th>
            <th>Typical use</th>
            <th>Notes</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>JSON</td>
            <td>JavaScript web apps (next-intl, i18next, vue-i18n)</td>
            <td>Native to JS, supports nesting, no comments</td>
        </tr>
        <tr>
            <td>YAML</td>
            <td>Ruby on Rails, some static site generators</td>
            <td>Allows comments, indentation-sensitive</td>
        </tr>
        <tr>
            <td>PO / gettext</td>
            <td><a href="/blogs/django-i18n-gettext-guide/">Django</a>, PHP, Linux and C projects</td>
            <td>Source text as the key, translator comments, built-in plural forms</td>
        </tr>
        <tr>
            <td>XLIFF</td>
            <td>Exchange with translation agencies and CAT tools</td>
            <td>XML standard holding source and target side by side</td>
        </tr>
        <tr>
            <td>ARB</td>
            <td>Flutter</td>
            <td>JSON-based, ICU messages, metadata in <code>@key</code> entries</td>
        </tr>
        <tr>
            <td><code>.strings</code> / <code>.xcstrings</code></td>
            <td>iOS and macOS</td>
            <td>Apple's string catalogs; <code>.xcstrings</code> is JSON-based</td>
        </tr>
        <tr>
            <td><code>strings.xml</code></td>
            <td>Android</td>
            <td>XML resources with a dedicated <code>plurals</code> element</td>
        </tr>
    </tbody>
</table>

<p>
    One limitation stands out: JSON has no comments, so there is nowhere to tell a translator that "Save" is a verb on a button and not a noun. Teams work around this with a separate description file, a context field in their translation platform, or descriptive key names. If you are standardizing on JSON, see <a href="https://docs.babelize.co/features/file-formats" target="_blank" rel="noopener noreferrer">how Babelize handles JSON locale files</a>.
</p>

<h2>Common Mistakes With JSON Locale Files</h2>

<ul>
    <li><strong>Concatenating translated fragments.</strong> Build whole sentences with placeholders, never by joining keys.</li>
    <li><strong>Reusing one key in two contexts.</strong> Key by meaning, so each context can be translated independently.</li>
    <li><strong>Key drift between locales.</strong> A key added to <code>en.json</code> but not to <code>de.json</code> silently falls back to English. A key renamed in one file but not the others is the same bug in reverse.</li>
    <li><strong>Invalid JSON.</strong> Trailing commas, single quotes, and comments are all illegal in JSON and stop the locale from loading.</li>
    <li><strong>Duplicate keys.</strong> <code>JSON.parse</code> does not complain about them; the last value wins, so an earlier translation silently disappears.</li>
    <li><strong>Encoding problems.</strong> Save files as UTF-8 without a byte order mark. A BOM makes <code>JSON.parse</code> throw, and non-UTF-8 encodings turn accents into garbage.</li>
    <li><strong>One giant file.</strong> Thousands of keys in a single file make reviews painful and force every page to load every string.</li>
</ul>

<h2>Catch Missing Keys in CI</h2>

<p>
    Key drift is easy to detect automatically. This Node script flattens every locale file and compares its keys against <code>en.json</code>, failing the build if any are missing or extra:
</p>

<pre><code>// scripts/check-locales.mjs
import fs from "node:fs";
import path from "node:path";

const dir = "messages";
const flatten = (obj, prefix = "") =&gt;
    Object.entries(obj).flatMap(([key, value]) =&gt;
        value &amp;&amp; typeof value === "object" ? flatten(value, `${prefix}${key}.`) : [`${prefix}${key}`]
    );
const keysOf = (file) =&gt; new Set(flatten(JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"))));

const source = keysOf("en.json");
let failed = false;

for (const file of fs.readdirSync(dir).filter((f) =&gt; f.endsWith(".json") &amp;&amp; f !== "en.json")) {
    const keys = keysOf(file);
    const missing = [...source].filter((k) =&gt; !keys.has(k));
    const extra = [...keys].filter((k) =&gt; !source.has(k));
    if (missing.length || extra.length) {
        failed = true;
        console.error(`${file}: missing [${missing.join(", ")}] extra [${extra.join(", ")}]`);
    }
}

process.exit(failed ? 1 : 0);</code></pre>

<p>
    Because <code>JSON.parse</code> runs on every file, the script also fails on invalid JSON. If you use i18next plural suffixes, strip <code>_zero</code>, <code>_one</code>, <code>_two</code>, <code>_few</code>, <code>_many</code>, and <code>_other</code> before comparing, since each language legitimately has a different set. To catch strings that never made it into a JSON file at all, pair this with pseudo-localization testing.
</p>

<h2>Type-Safe Translation Keys</h2>

<p>
    With TypeScript, you can make a typo in a key a compile error instead of a blank label. Both major libraries type keys from your source-language JSON file through module augmentation. In next-intl:
</p>

<pre><code>// global.d.ts
import messages from "./messages/en.json";

declare module "next-intl" {
    interface AppConfig {
        Messages: typeof messages;
    }
}</code></pre>

<p>And in react-i18next:</p>

<pre><code>// i18next.d.ts
import "i18next";
import common from "./locales/en/common.json";

declare module "i18next" {
    interface CustomTypeOptions {
        defaultNS: "common";
        resources: { common: typeof common };
    }
}</code></pre>

<p>
    With either in place, <code>t("auth.logn")</code> fails type checking and your editor autocompletes valid keys.
</p>

<h2>Frequently Asked Questions</h2>

<h3>What is a JSON translation file?</h3>
<p>
    A JSON translation file stores an application's user-facing text for one language as key-value pairs, for example en.json for English and fr.json for French. The code references keys, and the i18n library loads the file for the active locale and returns the matching value.
</p>

<h3>How should I name JSON locale files?</h3>
<p>
    Name each file after a BCP 47 language tag, such as en.json, fr.json, or pt-BR.json, using hyphens rather than underscores. Add a region only when content differs between regions. For larger apps, use one folder per locale with a JSON file per feature namespace.
</p>

<h3>Should translation keys be flat or nested?</h3>
<p>
    Both work, and most libraries read either shape with dot notation such as auth.login.button. Nested files are easier to scan and let you scope translations to a component; flat files are easier to search and diff. Choose one shape per project and use it consistently.
</p>

<h3>How do I translate a JSON file without breaking it?</h3>
<p>
    Translate only the values, never the keys. Keep placeholders such as {name} or {{count}}, ICU keywords like plural and other, and rich-text tags exactly as they are. Save the file as UTF-8, escape double quotes, and parse every file in CI to catch invalid JSON.
</p>

<h3>What happens when a translation key is missing?</h3>
<p>
    Most libraries fall back instead of crashing. i18next tries the fallback language chain and then shows the key itself. next-intl reports the error and renders a fallback, by default the key path. Users see English or a raw key, so check for missing keys in CI.
</p>

<h2>Final Thoughts</h2>

<p>
    JSON translation files look simple, and that is their strength: one file per locale, identical keys, values that change. The work is in keeping them that way. Use meaningful nested keys, BCP 47 file names, placeholders instead of concatenation, and real plural syntax. Translate values only, validate every file, and fail the build when locales drift apart.
</p>

<p>
    Keeping ten locale files in sync by hand is the part that does not scale. <a href="https://www.babelize.co/" target="_blank" rel="noopener noreferrer">Babelize</a> detects new and changed keys in your source JSON, generates placeholder-safe translations for every locale, and commits them back to your repository. You can run it from the terminal with the <a href="https://docs.babelize.co/cli" target="_blank" rel="noopener noreferrer">Babelize CLI</a>.
</p>
---
- [All articles](https://www.babelize.co/blogs)