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.

The four segments of a well-formed key A key is read left to right as namespace, surface, element and an optional variant: the namespace names the file and its owning team, the surface names where the string renders, the element names what the string is, and the variant distinguishes a state such as a disabled or error form of the same control. Anatomy of a catalogue key namespace checkout one file, one owner surface payment-form where it renders element submit what it is variant disabled optional state
Four segments, read left to right — each one answers a question a translator or a reviewer will actually ask.

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 pathcheckout.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.

Source-text, hashed and structured key schemes Using the source text as the key reads perfectly but changes identity whenever the copy is edited. An opaque hash never collides and never breaks, but nothing about it is readable to a human. A structured path reads well and only needs maintenance when a surface is renamed, which is why it suits most product teams. Three key-naming schemes compared Reads as Breaks when Best for Source text as key the English string copy is edited small, stable apps Opaque hash a4f9c2 and so on never, but nothing is readable machine pipelines Structured path checkout.form.submit a surface is renamed most product teams
The middle row is what tooling prefers and humans hate; the third row is the working compromise.

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.

States a translation key moves through A key is proposed in a branch, becomes live when it merges and is translated, is marked deprecated when its last call site is deleted, and is removed entirely one release cycle later. A rewritten source string does not mutate a live key; it produces a new proposed key while the old one deprecates. The life of a catalogue key proposed in a branch live translated, shipping deprecated shipped, not used removed deleted everywhere merged last call site deleted after one release copy rewritten — a new key, not a mutation
The bottom edge is the rule that matters: rewriting the copy makes a new key rather than changing an old one.

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>
Decision rights across the catalogue lifecycle The engineer chooses the key and its namespace at the moment the string is written. The reviewer decides whether it belongs where it was put, during pull request review. The translator decides the target wording once the key is live. Continuous integration decides whether the result may ship, on every merge. Who decides what, and when Decides At which moment Engineer the key and its namespace when the string is written Reviewer whether it belongs there at pull request review Translator the target wording after the key is live CI whether it may ship at every merge
Four roles, four moments — governance fails when one role starts making another role decisions.

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.

Part of Core i18n Architecture & Locale Negotiation.