Most i18n bugs aren't discovered by developers. They're discovered by a Polish user who sees "5 wiadomości" rendered as "5 wiadomość", or by an Arabic user staring at a button that got clipped because the translated string is 40% longer than the English original.
The source article from M21Global, preparing software files for localisation, lays out the linguistic reasons these bugs happen: plural categories, gendered strings, word order. This article is the implementation side — how to actually build a pipeline and codebase that doesn't generate this class of bug in the first place.
Stop concatenating strings, even "just this once"
Every team knows concatenation is bad practice. Every team still does it under deadline pressure:
// Don't do this
const message = "User " + userName + " has " + count + " messages";
The fix isn't just moving to template literals. It's moving the whole sentence into a resource key with named placeholders, so the translator controls word order:
{
"messageCount": "User {userName} has {messageCount} messages"
}
With i18next:
import { useTranslation } from 'react-i18next';
const { t } = useTranslation();
t('messageCount', { userName: 'Alice', messageCount: 3 });
The translator working on the German or Arabic file edits the whole string, including reordering the placeholders if the grammar demands it. Your code never changes.
Implementing CLDR plural rules instead of if/else trees
If your codebase has logic like this, it will break for any language beyond English:
// Breaks for Polish, Russian, Arabic...
function pluralize(count) {
return count === 1 ? 'item' : 'items';
}
CLDR defines up to six plural categories (zero, one, two, few, many, other) depending on the language. i18next, ICU MessageFormat and gettext all implement this out of the box. With i18next, the resource file looks like:
{
"item": "{{count}} item",
"item_other": "{{count}} items"
}
For a language with more categories, like Polish:
{
"item_one": "{{count}} przedmiot",
"item_few": "{{count}} przedmioty",
"item_many": "{{count}} przedmiotów",
"item_other": "{{count}} przedmiotu"
}
The library picks the right key based on the CLDR rules for that locale. You never write the conditional yourself. If you're on ICU MessageFormat instead:
{count, plural,
one {# item}
other {# items}
}
Test checklist for any pluralized string: run it with 0, 1, 2, 5, 11, 21 and 100. Those values expose edge cases in Slavic and Arabic plural rules that 1 and 2 alone won't catch.
Automated tests that catch localisation bugs before a human does
Most teams test translations by eyeballing the UI in one or two languages. That doesn't scale and doesn't catch plural bugs. A better approach is a lint step in CI that checks:
- Every key in the base locale (
en.json) exists in every target locale file - No locale file has orphaned keys that don't exist in the base file
- Every pluralizable key has an
otherfallback - No placeholder (
{userName}) is missing from a translated string
A simple Node script for the placeholder check:
const fs = require('fs');
function extractPlaceholders(str) {
return [...str.matchAll(/{(\w+)}/g)].map(m => m[1]).sort();
}
function checkLocale(base, target) {
const errors = [];
for (const key in base) {
if (!target[key]) continue;
const basePH = extractPlaceholders(base[key]);
const targetPH = extractPlaceholders(target[key]);
if (JSON.stringify(basePH) !== JSON.stringify(targetPH)) {
errors.push(`Mismatch in "${key}": expected [${basePH}], got [${targetPH}]`);
}
}
return errors;
}
const en = JSON.parse(fs.readFileSync('en.json'));
const de = JSON.parse(fs.readFileSync('de.json'));
console.log(checkLocale(en, de));
Run this on every PR that touches a locale file. It catches a translator accidentally dropping {orderDate} before it reaches production, which is far cheaper than catching it after a customer reports a broken order confirmation email.
Picking a format that survives the round trip
The format you export from matters more than most teams assume:
| Format | Good for | Watch out for |
|---|---|---|
| JSON | React, Vue, Node backends | No native comment support, easy to lose context |
| .resx | .NET apps | Comments supported per string, verbose XML |
| .po/.pot | gettext, WordPress | Mature tooling, plural forms built in |
| XLIFF | Vendor handoff | Preserves placeholders and comments, harder to hand-edit |
| ARB | Flutter | Native ICU plural support |
If you're shipping JSON straight to a translation vendor without an intermediate format, you're relying entirely on key naming to convey context. That's fragile. A cheap workaround if you're staying in JSON is a parallel comments file or a _comment key convention:
{
"checkoutButton": "Complete order",
"checkoutButton_comment": "Primary CTA, checkout screen, max 20 characters"
}
It's not as clean as .resx metadata or XLIFF's <note> elements, but it stops the translator from guessing.
Wiring this into CI/CD
For teams shipping continuously, the manual export-translate-import cycle doesn't scale. A reasonable pipeline:
- Developer adds a new key to
en.jsonas part of a feature PR - CI lint step flags missing placeholders/comments before merge
- On merge to main, a script diffs the base locale against the last exported version and pushes only new/changed keys to the translation platform (Crowdin, Lokalise, Phrase, or an XLIFF export for a vendor)
- Translated strings come back via webhook or scheduled pull, open as an automated PR
- A second lint pass validates placeholder parity and plural completeness before that PR can merge
This keeps translation off the critical path for shipping a feature in your base language, while making sure localisation debt doesn't silently accumulate.
Where the two articles meet
The M21Global piece makes the case for why resource files need to be CLDR-aware, placeholder-named and context-commented before they ever leave engineering. Everything above is how you enforce that mechanically, with tests and CI gates instead of a style guide nobody reads. If you're setting this up for the first time, start with the lint script. It's the cheapest way to turn a localisation convention into something that actually holds.
Top comments (0)