A translation key is a string nobody checks. Here is how to check it by hand, in the IDE and in CI.
Look at this line:

<button>{t('checkout.pay_nwo')}</button> 

The build passes and so do the tests. In production, the button reads checkout.pay_nwo.

My own start with translation keys was a migration. An existing application was going international, so every text in the code had to move into JSON files, in French, English, Spanish and Italian. Carrying all of that over by hand was the hardest part of the job. It also left me with four files that had to stay in step with the code, and nothing to check that they did.

Day to day, the cost is smaller but constant. To know whether a key exists, in which namespace and what it says, you open en/common.json, hit Ctrl+F, scroll and come back. Multiply that by 200 keys and 5 locales.

A translation key is a plain string that points to a line in a JSON file, and by default nothing verifies that the line exists. i18next's typed keys catch this particular typo, if you have set them up. They do not tell you that the German file lacks the key, or that half of your French file is no longer used by any component.

Four ways a key goes wrong

  1. The code calls a key that no file holds. A typo, or a key deleted during a cleanup. The user sees the raw key.
  2. A key exists in English and not in French. i18next falls back to English without a word, so nobody notices until a French customer does.
  3. A file holds a key that no code calls. It does no harm on screen, but translators keep translating it and the files keep growing.
  4. A placeholder changes in translation. Hello {{name}} becomes Bonjour {{nom}}, and the French page greets the user with {{nom}}.

None of these fails a build.

Checking by hand

The second case is the easiest to script. With jq, list every key path of two files and compare them:

diff <(jq -r 'paths(scalars) | join(".")' locales/en/common.json | sort) \ <(jq -r 'paths(scalars) | join(".")' locales/fr/common.json | sort) 

Each line of the output is a key one locale has and the other lacks.

This works for one pair of files. Then the limits show:

  • You need one run per namespace and per locale.
  • Unused keys need the opposite search, each key looked up in the code. A grep for user.name misses t('common:user.name') written under useTranslation('common'), and it cannot know that t(`status.${kind}`) uses every key under status.
  • It runs when someone remembers to run it, which is after the bug.

So the script is fine for an audit before a release. For daily work I wanted the editor to tell me.

Checking in the editor

During that migration I used I18n Support, a plugin by Evgeniy Nyavro. That plugin was no longer maintained. Built for older versions of the IDE, it was buggy and kept crashing IntelliJ. When the job was over, I made myself a promise: with some free time, I would fork it and just update the dependencies.

"Just" did not last. One feature followed another 😅, and the fork became I18n Support Plus, an open-source (MIT) plugin for WebStorm, IntelliJ IDEA Ultimate and PhpStorm. I maintain it, and the rest of this article uses it, so read it with that in mind.

With the plugin installed, a key gets an icon in the gutter: green when every locale holds it, yellow when some lack it, red when none does. Its value follows it as an inline hint, which saves opening the JSON file to remember what checkout.pay_now says. Ctrl+hover gives the value in every locale, with a dash where one is missing, and Ctrl+click opens the translation file on the right line. For a whole file, Show Translations Inline replaces each key by its value, and the JSX reads like the real UI.

Ctrl+clicking a key opens its value in the translation file

A red key is the typo from the top of this article, or a key that was never created. Either way, Alt+Enter and Create missing key write the value in every locale without leaving the component.

Creating an unknown key with its values

The typo itself gets rarer, because completion after t(' lists the keys that exist, namespaces included, each with its value.

For the key that French lacks, the tool window has a table with one column per locale and a filter that keeps the keys missing or empty somewhere. Sync Keys then creates the absent ones in each locale, for every namespace at once, which is where the jq one-liner stopped.

The table view, one column per locale

Unused keys are the hardest to find by hand. Count Key Usages counts the code usages of every key, namespace included, and the ones at zero are the candidates for deletion. When a key needs a better name, Shift+F6 renames it in every locale and at every call site, like any other symbol.

Then there is the task I started with, moving hard-coded text into the files. Alt+Enter turns a text into a key and writes the value in every locale, and Internationalize… does the same for a whole file or folder, after a preview:

Extracting a JSX text into a key, its value written in every locale

Checking in CI

An editor check helps the person who has the plugin. The pull request of a colleague who does not have it needs a second net. The same checks run without an IDE, as a GitHub Action:

jobs: translations: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: IBRAHIMDANS/i18nSupportPlus@v1.18.0 with: args: --code 

The job fails on a key missing from a locale, on a placeholder lost in a translation (the fourth case) and on a key the code calls that no file holds. With --code it also reports the unused keys. It annotates the pull request, so the author sees the problem on the line that caused it.

What it does not do

  • Keys built at runtime. t(`status.${kind}`) is read as status.*: the keys under status are not reported as unused, but the plugin cannot know which value kind takes. A key that comes from a function parameter or a computed value is not followed at all.
  • VS Code. The plugin is for JetBrains IDEs, 2025.2 or later. On VS Code, i18n Ally covers the same ground.
  • Translating for you by default. Machine translation exists (a local Ollama, DeepL, Google and others) but stays off until you turn it on, and nothing is written before a preview.

Try it

The plugin is free. Install it from the JetBrains Marketplace, or search for I18n Support Plus in Settings › Plugins. A setup wizard detects the framework and the translation files on first launch. The examples here are i18next, but the plugin also reads vue-i18n, next-intl, Lingui, react-intl, Angular, Laravel and Symfony projects.

The code, the documentation and a sample project for each framework are on GitHub. Issues and feature requests are welcome there, and a star helps if the plugin saves you time.

One question to finish: how many times a day do you open a JSON file just to check whether a key exists? If the answer is not "never", I would like to hear how you deal with it.

I build tools that cut the daily friction of front-end work. Let's talk on LinkedIn.