String Catalog Governance
A catalogue starts as one file with forty keys and ends, two years later, as eleven files with four thousand — several hundred of which are no longer referenced by any code, a few dozen of which are duplicates with slightly different wording, and a handful of which were renamed in a refactor and quietly lost their translations. Nobody decided any of that. It is what happens when a catalogue has conventions but no governance.
Governance here means four concrete things: how keys are named, how the catalogue is split, what happens when a string changes, and who is allowed to decide each of those. None of it is glamorous, and all of it is cheaper to establish at forty keys than to retrofit at four thousand.
This page sits alongside the runtime concerns of Core i18n Architecture & Locale Negotiation: the resolver decides which bundle answers a key, and governance decides what the keys are and how they change.
Prerequisites
Concept & spec — a key is an identifier, not a label
The single most consequential decision is what a key is. Three schemes are in common use, and they behave very differently under change.
Using the source text itself as the key — the model Lingui and gettext default to — reads beautifully in code and needs no lookup to understand. Its weakness is identity: the key is the English copy, so editing a typo changes the identity of the string and orphans every translation of it. Tools mitigate this with fuzzy matching, but the mitigation is probabilistic.
An opaque hash of the source content, which is what @angular/localize generates when you do not supply an identifier, has the opposite properties. It never collides and it is stable against everything except a content change — which is precisely when it changes, silently, producing the drift described in Angular localize missing translation IDs.
A structured path — checkout.payment-form.submit — decouples identity from content entirely. The copy can be rewritten without touching the key, which means translations survive editorial passes. The cost is that someone has to choose the path, and paths need maintenance when the product is reorganised.
For most product teams the structured path wins, for one reason: copy changes far more often than structure does. A key that survives a rewording is a key whose translations survive a rewording, and over a year that is the difference between a catalogue that accumulates value and one that churns.
Naming rules worth enforcing
Four rules cover nearly every case, and each one exists because its absence produces a specific, recurring argument.
Keys are lowercase with hyphens inside segments and dots between them, because mixed conventions make search unreliable and every team eventually needs to grep the catalogue.
Keys describe what the string is, never what it says. checkout.submit survives a copy change; checkout.save-changes-button becomes a lie the moment the label becomes “Continue”.
Keys are never assembled at runtime. t('status.' + code) is invisible to extraction, which means the key is absent from the catalogue and the string is missing in every locale but the one where the developer tested. This is the single most common cause of the missing-key warnings covered in detecting hardcoded strings. Write an explicit map instead.
Keys are unique across namespaces, or explicitly namespaced at the call site. Two namespaces both defining title is fine; code that resolves title without saying which namespace it means is not.
Step-by-step implementation
1. Split the catalogue by ownership, not by size
The instinct is to split when a file gets long. The better axis is who is responsible for the copy. A namespace should map to a product surface with an owner, because that is what makes a review meaningful.
locales/
en/
common.json # shared: actions, errors, units — changes rarely, reviewed carefully
checkout.json # owned by payments
onboarding.json # owned by growth
settings.json # owned by platform
Namespaces also become the loading unit. A checkout page loads common and checkout and nothing else, which is what keeps a locale bundle proportional to the page rather than to the product.
2. Make deprecation a state, not a delete
Deleting a key the moment its last call site disappears is how translations get lost — a revert, a cherry-pick or a feature flag turning back on will resurrect the call site against an absent key. Mark it instead, and delete on a schedule.
{
"checkout.legacy-banner.title": "Save 20% today",
"_meta": {
"checkout.legacy-banner.title": {
"deprecated": "2026-06-01",
"reason": "banner removed in the pricing refresh",
"removeAfter": "2026-09-01"
}
}
}
A weekly job removes anything past removeAfter, and the record of why survives in version control where the next person will find it.
3. Never mutate a live key’s meaning
If the meaning of a string changes, the key must change too. This is the rule that translators care about most, because a translated string is an answer to a specific source question — and silently changing the question leaves every locale with a confidently wrong answer.
The mechanical version of the rule: a copy edit that a translator would want to know about is a new key. Fixing a typo is not. Changing “Delete account” to “Deactivate account” is, because the German translation of the first is simply wrong for the second.
4. Enforce the conventions in CI
A convention that is not checked is a suggestion. Three checks cover the rules above and take under a second on a large catalogue.
const KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)+$/;
test('every key is well formed', () => {
const bad = Object.keys(catalog).filter((k) => !k.startsWith('_') && !KEY.test(k));
expect(bad, `malformed keys: ${bad.join(', ')}`).toEqual([]);
});
test('no key is defined in two namespaces', () => {
const seen = new Map<string, string>();
for (const [ns, entries] of Object.entries(namespaces))
for (const key of Object.keys(entries)) {
const prior = seen.get(key);
expect(prior, `${key} defined in both ${prior} and ${ns}`).toBeUndefined();
seen.set(key, ns);
}
});
test('no key is referenced but undefined', () => {
expect([...referencedInSource].filter((k) => !(k in catalog))).toEqual([]);
});
5. Give the translator context in the catalogue, not in a chat thread
A key, a source string and nothing else is an ambiguous brief. Is “Open” a verb or an adjective? Is this a button or a status? Length-constrained or free? Every translation format has somewhere to put that — #. comments in PO, <notes> in XLIFF — and the extraction step can carry it automatically from a comment at the call site.
// i18n: verb — button that opens the shared document. Max 12 chars.
<button>{t('documents.share.open')}</button>
Configuration reference
| Convention | Value | Why this default |
|---|---|---|
| Key format | namespace.surface.element[.variant] |
Readable, greppable, and stable across copy edits. |
| Case | lowercase, hyphens within a segment | Removes the mixed-case ambiguity that breaks search. |
| Namespace granularity | one per owned product surface | Makes review meaningful and bundles proportional. |
| Runtime key construction | forbidden | Extraction cannot see it; the key is missing everywhere but locally. |
| Deprecation window | one release cycle, minimum | Survives reverts, cherry-picks and re-enabled flags. |
| Meaning change | always a new key | A translated string answers the old question. |
| Context notes | required for any non-obvious string | Removes the most common source of mistranslation. |
Framework variants
React / i18next. Namespaces are first class: useTranslation('checkout') scopes lookups and controls which files load. Set nsSeparator and keySeparator explicitly rather than relying on defaults, because a key containing a colon, or a dot in a namespace-aware resolver, resolves somewhere surprising.
Vue / Nuxt. Global and local scopes give you a second axis alongside namespaces. Use local scope for genuinely self-contained widgets and the global catalogue for everything else — a component-local block that grows past a dozen keys is a namespace that has not been extracted yet.
Angular. With build-time inlining, custom identifiers (i18n="@@checkout.submit") are the governance mechanism. Making them mandatory in review is what prevents the content-hash drift that otherwise silently orphans translations after every copy edit.
Node.js backend. Server strings — emails, receipts, webhook messages — deserve their own namespaces and often their own review process, because their copy is frequently subject to legal or compliance constraints that product copy is not.
Verification
npx vitest run i18n/governance # key format, namespace collisions, undefined references
npx tsx scripts/find-orphans.ts # keys with no call site in source
# Expected
# ✓ 4182 keys, 0 malformed
# ✓ 0 keys defined in more than one namespace
# ✓ 0 keys referenced but undefined
# ⚠ 37 keys with no call site — 37 already marked deprecated, 0 unexplained
The last line is the one to watch over time. Unexplained orphans trending upward means deprecation is not being recorded, and the catalogue is drifting back toward the state this page exists to prevent.
Migrating an ungoverned catalogue
Most teams arrive at governance with a catalogue that already has four thousand keys, no consistent naming, and no record of which keys are still used. Rewriting it in one pass is possible and almost always a mistake: every renamed key is a lost translation unless the rename is carried through every locale, and a four-thousand-key diff is unreviewable.
The sequence that works treats the existing catalogue as legacy and makes the new rules apply only to new keys.
Freeze the old shape. Nothing in the existing catalogue is renamed. It keeps working, keeps its translations, and stops growing.
Apply the rules going forward. New keys follow the naming convention and land in an owned namespace. Enforce this in CI on added keys only, which is a diff-scoped check rather than a whole-file one:
# only keys added in this branch have to satisfy the convention
git diff origin/main -- 'locales/en/*.json' \
| grep -E '^\+\s+"' \
| grep -vE '^\+\s+"[a-z0-9-]+(\.[a-z0-9-]+)+"' \
&& { echo 'new keys must use the structured naming convention'; exit 1; }
Migrate by surface, when you are there anyway. When a team touches a product surface for other reasons, its keys move to the new namespace in the same pull request, with all locales carried across in that one commit. The cost is amortised into work that was happening regardless, and each migration is a reviewable size.
Retire what is unused, on a delay. Run the orphan scan and mark, rather than delete, anything with no call site. After a release cycle, the marked keys that are still unreferenced can go. Doing this before the naming migration is usually worth it — a substantial fraction of an ungoverned catalogue turns out to be dead, and there is no point migrating keys that should not exist.
A realistic timeline for this on a large product is two or three quarters, and it is genuinely finished, which a big-bang rewrite frequently is not. The measure of progress is not the percentage of keys migrated but the percentage of changed keys that follow the convention — because that number reaching one hundred percent is what stops the problem growing.
Reviewing a catalogue change
A catalogue diff answers a different question from a code diff, and reviewing it as if it were code misses the things that actually go wrong. Four questions cover it.
Is the key in the right namespace? A string that renders on the checkout page but lands in common.json is now shared, which means changing it later will change it somewhere the author never looked. Shared is a commitment, not a convenience.
Does the key describe the string’s role rather than its text? checkout.confirm survives the copy becoming “Place order”; checkout.pay-now-button does not. This is the single most common review comment on a young catalogue and the one that pays back the most.
Does a non-obvious string carry a note? Any string that is a single word, that has a length constraint, or whose grammatical role is ambiguous needs context. A reviewer asking “would a translator know what this is?” catches almost all of them.
Are removals accounted for? A deleted key should be a deprecation, and a deprecation should say why. A bare deletion in a diff is worth a question every time, because it is either a lost translation or an undocumented decision.
None of this needs to be slow. On a normal pull request the catalogue diff is a handful of lines, and these four questions take under a minute. The reason to make them explicit is that they are easy to skip precisely when the catalogue is changing fastest — during a feature push, which is exactly when the conventions matter most.
Common pitfalls
- Splitting by file size. Namespaces drawn along arbitrary size boundaries have no owner, so nobody reviews them and everything lands in
misc.json. - Reusing a key because the English matches. Two surfaces whose English strings coincide will diverge in other languages, where the same word takes a different form. Never share a key across surfaces for convenience.
- Deleting on the same commit that removes the call site. One revert later, the key is gone and the string is missing in every locale.
- Letting the catalogue be hand-edited. The source catalogue is generated. A hand-edit is a change that no extraction run will preserve.
- Notes in a chat thread. Context that is not in the catalogue does not reach the translator, who is working in a different tool weeks later.
FAQ
Should keys be flat or nested?
Flat keys with dotted paths are easier to grep, diff and merge; nested objects read better in a file and produce smaller diffs when a whole surface changes. Pick one and enforce it, because mixing them makes a key’s identity ambiguous — a.b.c may be a flat key or three levels of nesting, and different tools resolve that differently.
How do I rename a namespace without losing translations?
Move the entries and their translations together in one commit, keeping the leaf keys identical, and leave an alias mapping the old namespace to the new one for a release cycle. What loses translations is renaming keys and moving them in separate commits, because the intermediate state looks like a delete followed by an unrelated create — the full procedure is in safely renaming a translation key.
Who should own the source catalogue?
Engineering owns the keys and their structure; the content or localization team owns the wording. That split matters because the two change for different reasons and at different rates. A shared owner in practice means neither reviews it.
Is one big catalogue file ever acceptable?
For a small application with a single team, yes — the overhead of namespacing outweighs its benefit under a few hundred keys. The signal to split is not size but review: when a catalogue diff routinely needs two different people to approve it, it should be two files.
What belongs in a shared namespace?
Only strings whose meaning is genuinely context-free: “Cancel”, “Save”, currency and unit labels, generic error titles. The moment a shared string needs a variant for one surface, it was not shared — it was two strings that happened to match in English, and other languages will prove it.
Related
- Safely renaming a translation key — the one-commit procedure that stops a rename archiving every translation.
- Duplicate keys across namespaces — why an unqualified lookup resolves by load order rather than by intent.
- Finding orphaned keys after a refactor — an AST-based sweep that marks unused keys instead of deleting them.
- Splitting a monolithic catalog into namespaces — migrating one surface at a time with the old catalogue still loaded.
- ICU Message Format Deep Dive — the message syntax the catalogue stores.
- Translation Memory & Glossary Management — how stable keys compound into leverage over time.
- Localization Testing & Pseudolocalization — verifying that every rendered string came from the catalogue.
- GitHub Actions i18n CI Gates — where these conventions become enforceable.
- Extracting translation keys with i18next-parser — the extraction step that must produce the catalogue.