The two shapes
Every locale file is one of these two, and mixing them in one project is the only choice that is definitely wrong:
{
"cart": {
"summary": {
"title": "Your cart",
"empty": "Your cart is empty"
}
}
}{
"cart.summary.title": "Your cart",
"cart.summary.empty": "Your cart is empty"
} Same keys, same values, same lookups in most libraries. The difference is not what your code calls — t('cart.summary.title') either way — it is what happens to the file over the next two years: how it diffs, how it merges, what a translation tool does to it, and whether a key can go missing without anyone noticing.
This is about the structure inside one file. Splitting a language across several files is a separate decision with separate trade-offs — see splitting translations into multiple files. You can make either choice with either shape. Neither shape changes what is inside a value, which is its own subject: placeholder validation.
What libraries expect
Mostly both, with a setting that decides how a dot in a key is read. That setting is the whole story, and it is the one people find out about by accident:
| Library | Default | The setting that matters |
|---|---|---|
| i18next | Nested | keySeparator: '.' — set it to false and keys with dots are read literally, which is what makes flat files work. |
| Transloco | Nested | Flattens the loaded object into dotted keys, so a nested file and the equivalent flat file behave identically at lookup time. |
| vue-i18n | Nested | Resolves a dotted key as a path first. A literal dot in a key needs escaping, so flat files are the awkward option here. |
| react-intl / FormatJS | Flat | Message ids are opaque strings. Dots are just characters; nothing walks a path. |
Two consequences worth writing down. First, the library rarely forces your hand — so “what does i18next want?” is not the question, and the answer below has to come from somewhere else. Second, anything outside your app is likelier to want flat: XLIFF unit ids, gettext msgids and most translation-tool exports are flat, so a nested file gets flattened on the way out and rebuilt on the way back in.
Diffs, greps and merges
This is where the two shapes actually differ, and all three of these are things you do weekly.
Grep
Flat wins outright. You see cart.summary.title in a component, and grep -rn 'cart.summary.title' src/locales finds it in every language. In a nested file that string does not exist anywhere — you search for title, get sixty hits, and start counting indentation. Every editor has a way around this and it is still friction, several times a day.
Diff
Flat wins again, less obviously. Renaming a nested parent rewrites every line under it, so a rename that moves eleven strings looks like eleven changed strings in review. Flat keys each occupy exactly one line: a rename is a rename, a new string is one added line, and a reviewer can read the diff without opening the file.
Merge
Roughly even, and worse than people expect either way. Two branches appending keys to the same region conflict textually in both shapes — see translations on feature branches for what to do about that. Flat has a small edge because a sorted flat file spreads insertions across the file rather than concentrating them at the end of an object.
Reading it
Nested wins this one, and it is not a small thing. A nested file has structure a person can see: the checkout strings are together, the section is visibly complete or visibly not. A flat file of 900 lines is a list, and a list sorts but does not group.
The collision nobody plans for
There is one failure that belongs specifically to this choice, and it happens when a key is both a value and a container:
{
"cart": "Cart",
"cart.summary.title": "Your cart"
}
// Valid JSON. Valid flat keys. Impossible to nest:
// "cart" cannot be a string and an object at once. In a nested file this cannot be expressed at all — cart is either a string or an object. In a flat file it is perfectly legal JSON, and it breaks at the moment somebody converts the file to nested: the conversion has to choose which of the two to throw away, and most implementations quietly choose.
The mirror image is worse. Converting flat to nested is not reversible, because a flat key containing a dot is ambiguous: "errors.404" could be a two-level path or one literal key naming an HTTP status. Version numbers, filenames, domain names and error codes all end up in keys, and all of them contain dots.
If you are going flat, forbid dots in the leaf part of a key by convention and check it in CI. It costs nothing while the file is small and it is unfixable once it is not.
Converting without losing a key
Converting is twenty lines and the twenty lines are not the risky part. The risk is that the conversion loses keys and nobody notices, so the only version worth writing is the one that counts:
import { readFileSync, writeFileSync } from 'node:fs';
const flatten = (value, prefix = '') =>
Object.entries(value).flatMap(([key, inner]) => {
const path = prefix ? `${prefix}.${key}` : key;
return typeof inner === 'object' && inner !== null
? flatten(inner, path)
: [[path, inner]];
});
const source = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const entries = flatten(source);
const flat = Object.fromEntries(entries);
// The whole reason to write the script rather than paste one:
// Object.fromEntries silently keeps the last of a duplicate.
if (Object.keys(flat).length !== entries.length) {
throw new Error('duplicate keys after flattening — see above');
}
writeFileSync(process.argv[2], JSON.stringify(flat, null, 2) + '\n');The assertion at the end is the point of the script. A locale file has no schema and no tests, so a conversion that drops four strings out of nine hundred produces a file that looks entirely normal, passes review, and shows up as four missing translations three weeks later.
Run it on every language in the same commit, and diff the key sets between languages afterwards rather than the files. Two languages disagreeing about which keys exist is the actual thing you are trying to prevent.
Choosing one
Short version, since the trade-offs above cancel each other out for most teams:
- Nested if a person reads and edits these files — if the file is grouped by screen and someone reviews a section at a time. This is most product teams, and it is why nested is the common default.
- Flat if the files are written by tools and read by greps — if you are on react-intl, or if key renames are frequent enough that diff noise is a real cost.
- Not both. One shape, enforced by whatever writes the files, checked in CI. A project that is 70% nested with a flat corner has all of the drawbacks and none of the benefits, and every script anyone writes against it has a special case.
Whichever you choose, the property that matters more than either is that the file is written deterministically: same content, same bytes, keys in a stable order. That is what makes a diff reviewable, and it is independent of this whole argument.
Where Mergua fits
Mergua reads and writes both, and the CLI takes the shape as a flag rather than guessing: nested is the default, and --format flat is for files that keep literally dotted keys. It writes them back deterministically, in a stable key order, which is the part of this page that survives whichever shape you pick.
The CLI reference has the flag and what it does to the round trip; the framework guides name the shape each stack expects.