Workflow

Your code stopped using forty keys. Your translators are still translating them.

Keys leave the code and stay in the locale files; keys arrive in the code and never reach them. One costs money, the other shows users a raw key — and the list that tells them apart is one your framework can already write.

Published Updated

Two ways keys drift apart

A translation key lives in two places: in the code that asks for it, and in the locale files that answer. Nothing ties the two together. A compiler checks that a function you call exists; it has no opinion on whether checkout.summary.title is in de.json. So the two lists drift, in both directions.

  • Unused keys — in the files, used nowhere in the code. A feature is removed, a component is rewritten, a label is renamed. The code changes in the pull request; the locale files rarely do, because nobody deletes a string they are not sure is dead.
  • Missing keys — used in the code, in no file. A developer writes t('cart.giftNote'), adds the English string locally or not at all, and the key never reaches the files the app actually loads.

Both are normal. Every project with more than a few hundred keys has some of each, and the question is not how to prevent the drift but how to see it before somebody else does.

What each direction costs

The two are not equally urgent, and treating them alike is how a check gets ignored.

Who noticesWhat it costs
MissingYour users, in production. The raw key, the fallback language or an empty element in the UI, depending on how your library handles a miss.
UnusedNobody, which is the problem. Every dead key is still translated, still reviewed, and still counted wherever keys are billed or measured. Progress bars include it; a translator asks what it means.

A missing key is a bug. An unused key is cost. That distinction decides almost everything about how to check for them, and it comes back at the end of this page.

Let the extractor keep the list

The obvious approach is a search: grep the source for every key in the locale file, and flag the ones that do not turn up. It works for an afternoon. Keys appear in templates, in TypeScript, behind a scope prefix, in a pipe, in a directive — and a regular expression that understands all of those is a parser written badly.

The second obvious approach is a hand-maintained list of keys the code uses. It rots the same way the locale files do, for the same reason: nobody edits it when they delete a component.

The list that stays true is the one a tool writes from the code itself. Every major i18n library has an extractor that walks the source and collects the keys it finds into a JSON file:

  • Angular with Transloco — Transloco Keys Manager.
  • React with i18next — i18next-cli.
  • Vue with vue-i18n — vue-i18n-extract.

Run it into a directory of its own rather than over the locale files your app loads: what you want from it is a list of keys, not a second copy of your translations. Each tool needs its own flags to write that list, and one of the three needs a small workaround to write one at all — the exact command per framework is in the CLI docs.

Once that file exists, finding the drift is a comparison of two key sets: in the extracted list and not in the locale file is missing, the other way round is unused.

Keys built at runtime

An extractor reads the code without running it, so it sees t('status.active') and cannot see this:

order-status.ts
// The extractor sees a string concatenation, not a key.
label = this.transloco.translate('status.' + order.status);

Every key that line can produce looks unused to the extractor. The fix is to name them where they are built, in a form the extractor reads — a marker function or a comment listing the keys. That keeps the declaration next to the code that needs it, so it is deleted when the code is.

Not every extractor has a way to declare them; the CLI docs say which do. Where yours does not, treat the unused list as a list of candidates rather than a list of deletions. A person decides that a key is dead; a comparison only says it was not found.

Which one should fail the build

Back to the distinction from the start. A check that turns a pipeline red has to be worth the interruption every single time, or it gets switched off within a week.

  • Missing keys can fail it. Each one is a string a user is about to see in the wrong form, and the fix is small and obvious: add the key.
  • Unused keys should not. They are not urgent, they cost nothing until the next translation round, and a dynamic key nobody declared would block an unrelated merge. Report them, and clean them up on purpose.

Start with both as warnings. Once the missing list is empty, make it fail the job — from then on it only ever contains the key somebody just added.

Where Mergua fits

Mergua does not scan your code. It reads the list your extractor writes, through the same --key-manifest the sync already takes, and compares it with the keys it holds. The CLI's check command does that comparison and writes nothing:

.gitlab-ci.yml
check-translation-keys:
  stage: test
  image: node:22-alpine
  before_script:
    - apk add --no-cache curl jq git
  script:
    # Your framework's extractor writes the keys the code uses.
    # The command per framework is in the CLI docs.
    - npm run i18n:extract
    - curl -sf https://api.mergua.com/v1/mergua.sh | sh -s -- check --key-manifest i18n-keys/en.json --strict

It compares against the keys a pull of the same branch would download, so “missing” means exactly “the file your pipeline pulls will not contain this key”. Both lists are printed as warnings and the job passes, so the step can join an existing pipeline without turning it red. --strict fails the job when a key is missing; unused keys never fail it, with or without the flag.

If your pipeline pushes the keys rather than only pulling them, point the sync at the extracted list instead of a hand-maintained one. A key the code no longer uses is then proposed as a deletion on the branch, and it is reviewed before the merge with the rest of the branch — which is also where an undeclared dynamic key gets caught.

Setup per framework, the check command and how a namespace is named are in the CLI docs.

Keep reading

Try it on one branch.

Upload the locale files you already have and see the whole loop — branch, translate, review, sync — on the free tier.

Create your account