DEV Community

Cover image for Building an i18n Pipeline That Doesn't Break on Plurals and RTL Languages
Diogo Heleno
Diogo Heleno

Posted on Originally published at m21global.com

Building an i18n Pipeline That Doesn't Break on Plurals and RTL Languages

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";
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

With i18next:

import { useTranslation } from 'react-i18next';

const { t } = useTranslation();
t('messageCount', { userName: 'Alice', messageCount: 3 });
Enter fullscreen mode Exit fullscreen mode

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';
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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 other fallback
  • 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));
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Developer adds a new key to en.json as part of a feature PR
  2. CI lint step flags missing placeholders/comments before merge
  3. 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)
  4. Translated strings come back via webhook or scheduled pull, open as an automated PR
  5. 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)