en vs en-US: How to Name Locale Files Without Breaking Your i18n Pipeline
You are setting up i18n. You create the folder. Then you stop, because you have to name the first file and there are two obvious answers: locales/en.json...
Table of Contents
You are setting up i18n. You create the folder. Then you stop, because you have to name the first file and there are two obvious answers:
locales/en.json # or
locales/en-US.json
Every guide picks one and moves on without saying why. The choice looks cosmetic. It is not: it decides whether your translation tooling can find that file later, and getting it wrong produces one of the least helpful error messages in localization — "source language files not found", on a repository where the files are plainly right there.
This post is about what the standards actually say, which direction of matching is safe, and which three cases will silently give you the wrong language.
The short answer
Pick one convention and use it for every file in the folder. Mixing en.json with fr-FR.json is worse than either choice made consistently.
If you have no other constraint:
- Bare codes (
en.json,fr.json****,ja.json****) when you ship one variant per language. This is most products. - Region codes (
en-US.json,pt-BR.json****) when you genuinely ship two variants of the same language and they differ in content, not just spelling.
The rest of this post is why, and what breaks when a tool has to bridge the two.
What the standards actually say
Three specs matter here, and they answer different questions.
RFC 5646 defines what a valid tag looks like: language, optionally followed by script and region. So en, en-US, zh-Hant, and zh-Hant-TW are all well-formed. It says nothing about which one you should use.
RFC 4647 defines how to match a requested tag against a list of available ones. Its "Lookup" scheme is the one everybody implements, and it works by progressively removing subtags from the right:
requested: en-US-u-va-posix
try: en-US-u-va-posix
try: en-US
try: en
Read that again, because it is the whole problem. Lookup only walks from specific to general. A user asking for en-US will happily be served en.json. A user asking for en will never be served en-US.json — the algorithm has no step that adds a subtag.
That asymmetry is deliberate and correct for content negotiation: serving generic English to someone who wanted American English is a graceful downgrade, while the reverse is a guess. But translation tooling is not doing content negotiation. It is trying to answer "which file on disk holds this language", and there the reverse direction is exactly what you need.
CLDR likely-subtags is the spec that fills that gap. It answers "given a partial tag, what is the most probable complete one", and every modern runtime ships it. You can check it in your own console right now:
new Intl.Locale("en").maximize().toString()
// "en-Latn-US"
new Intl.Locale("en-US").maximize().toString()
// "en-Latn-US" ← identical
Two tags that maximize to the same identity are the same locale written two ways. That is a fact you can compute, not a heuristic you have to tune.
Why this is not just theory
A customer connected a repository laid out like this:
src/i18n/locales/en-US.json
src/i18n/locales/ja-JP.json
src/i18n/locales/zh-TW.json
Their project's source language was en. Path correct, branch correct, file structure correct, files sitting right there. Four failed imports, zero keys, and this:
Source language files (en) not found under src/i18n/locales on main.
Found locale file(s) for [en-us, ja-jp, zh-tw]
The matcher had implemented RFC 4647 Lookup faithfully, which meant it walked specific to general and never the other way. en could not reach en-US.json. And the advice the error gave — change your source language to en-us — was wrong, because en and en-US were never two different languages here.
Three minutes after signing up, that account was stuck on a problem it had not caused and could not fix from its own side. That is what a "cosmetic" naming choice actually costs.
The three cases that will bite you
Maximization is safe, but only because it is not truncation. If you implement this yourself, or evaluate a tool that claims to, these are the cases to check.
1. en-GB is not en
new Intl.Locale("en").maximize().toString() // "en-Latn-US"
new Intl.Locale("en-GB").maximize().toString() // "en-Latn-GB"
Different identities, so a project whose source is en must not pick up en-GB.json. British English is different content, not a different spelling of the same content. Any implementation that gets here by chopping the region off the filename will match it, and will be wrong.
2. Bare zh means Simplified, and zh-TW is Traditional
This is the one that causes silent damage rather than a visible failure.
new Intl.Locale("zh").maximize().toString() // "zh-Hans-CN" ← Simplified
new Intl.Locale("zh-TW").maximize().toString() // "zh-Hant-TW" ← Traditional
new Intl.Locale("zh-Hant").maximize().toString() // "zh-Hant-TW"
Bare zh is not "Chinese, region unspecified". CLDR resolves it to Simplified Chinese. So any code that recovers a language by splitting the filename on a hyphen turns zh-TW.json into zh, which then means Simplified — and a Traditional Chinese file has quietly become a Simplified one. Nothing errors. Your Taiwanese users get the wrong script.
If you store a language code separately from the filename, store zh-Hant, not zh.
3. Two variants of one language must refuse to match
locales/pt-BR.json
locales/pt-PT.json
Bare pt maximizes to pt-Latn-BR, so a naive implementation matches pt-BR.json and reports success. It is also the wrong behaviour. A repository shipping both has deliberately separated two deliverables, and picking Brazilian because CLDR ranks it first is choosing one of the customer's products on their behalf.
The correct answer is to refuse and ask. pt against pt-BR.json alone is fine — there is one Portuguese file and CLDR says bare pt means Brazilian. pt against both is ambiguous and must fail loudly.
Notice that the same input (pt) gives different correct answers depending on what else is in the folder. This is why "just normalize the codes" does not work as a fix.
Import is only half of it
Matching a file on the way in is the easy half. The half that produces corrupted repositories is writing back.
You have a repository using en-US.json, ja-JP.json, zh-TW.json. Your tool matched them correctly on import and stored the languages as en, ja, zh-Hant. Now someone adds French. What filename does the tool create?
If it writes the stored code, you get:
locales/en-US.json
locales/ja-JP.json
locales/zh-TW.json
locales/fr.json ← nobody reads this
A file the application never loads, sitting next to the ones it does. No error, no failed job. The translations are simply invisible, and the person who added French has no reason to suspect anything.
The fix is to treat the naming convention as a property of the repository, inferred from what is already there rather than from the language code:
| Files present | Inferred convention | New fr becomes |
|---|---|---|
| en-US, ja-JP, zh-TW | language-region, hyphen | fr-FR.json |
| en, ja, zh-Hant | bare language | fr.json |
| en_US, ja_JP | language-region, underscore | fr_FR.json |
Import and publish have to agree on this. If they disagree, every write lands somewhere the read never looks.
What to do in your own project
If you are starting fresh. Use bare codes unless you have a concrete second variant. en.json costs nothing and is compatible with every matcher, including ones that only implement RFC 4647. en-US.json requires the tooling around it to be smarter, for no benefit until an en-GB.json actually exists.
If your framework picked for you. Keep it. next-intl, i18next, vue-i18n and the rest all accept either, and consistency inside the folder matters more than which one you chose. Renaming an established set of files to satisfy a tool is the wrong trade — the tool should handle your repository as it is.
If you already have both. Fix it now, while it is one commit. en.json and en-US.json in the same folder is a coin flip for anything reading that directory, and the coin lands differently in different tools.
If your source language file is not being found. Before touching the path, compare the exact filename against your configured source code. In our failure data this specific mismatch — code and filename describing the same language in two spellings — was the single largest cause, ahead of genuinely wrong paths.
Testing it without a translation platform
You do not need any tooling to check whether two locale spellings mean the same thing:
function sameLocale(a, b) {
return new Intl.Locale(a).maximize().toString()
=== new Intl.Locale(b).maximize().toString();
}
sameLocale("en", "en-US") // true
sameLocale("en", "en-GB") // false
sameLocale("zh-Hant", "zh-TW") // true
sameLocale("zh", "zh-TW") // false ← the trap
sameLocale("pt", "pt-BR") // true
Intl.Locale is available in every current browser, Node 14+, Deno and Bun. Five lines, no dependency, and it will answer most naming arguments faster than the discussion would.
How Better i18n handles it
Our first implementation was RFC 4647 Lookup, which is the textbook answer and left that customer with four failed imports. What we ship now:
- Matching goes both directions. A source language of
enresolvesen-US.jsonthrough CLDR maximization, and it is reported as amaximizedmatch rather than an exact one, so the distinction stays visible. - Ambiguity fails instead of guessing.
ptagainst bothpt-BR.jsonandpt-PT.jsonrefuses and asks, rather than picking the statistically likelier one. - An exact filename always wins. If
en.jsonexists, it is used, and the maximization path is never reached. - The repository's convention is inferred and reused on write. Adding
frto a repository ofen-US.jsonfiles producesfr-FR.json, so a publish lands in the file the import read. - Failures name the fix. Instead of "source language files not found", the error names the files it did see and the configuration that would have worked.
All of it runs against a public fixture repository of real customer layouts, including the one above: github.com/better-i18n/i18n-sync-fixtures
The naming choice is small. The class of bug it creates is not, because every symptom points somewhere else — at your path, your branch, your file structure — and the actual cause is two spellings of the same language sitting in different config fields.
Pick a convention, keep it consistent, and check that whatever reads your repository can bridge the two directions before you find out the hard way.