All posts

i18n Best Practices That Most Guides Get Wrong

The i18n advice you actually need: string concatenation traps, RTL layout gotchas, complex plural rules, and hardcoded error messages that will bite you in production.

Every i18n tutorial starts the same way: extract your strings into JSON files, use a library like react-intl or i18next, done. That advice isn't wrong, but it covers maybe 20% of what actually breaks when you ship to new locales. Here's the other 80%.

String concatenation is a localization landmine

This looks harmless:

const message =
  t("welcome") +
  ", " +
  userName +
  "! " +
  t("youHave") +
  " " +
  count +
  " " +
  t("newMessages");

It's not. In Japanese, the grammar rearranges so the count comes before the greeting context. In Arabic, the sentence structure differs entirely. In German, compound words and case endings change based on surrounding context.

The fix is to use parameterized strings with full-sentence translation units:

{
  "welcomeMessage": "Welcome, {userName}! You have {count} new messages."
}
t("welcomeMessage", { userName, count });

This gives translators the full sentence so they can reorder placeholders as the target language requires. One string, one translation unit. No exceptions.

Plural rules are way more complex than "1 vs many"

English has two plural forms: singular and plural. Polish has four. Arabic has six. If your i18n setup only handles one and other, you're producing broken Polish and Arabic from day one.

The CLDR plural rules define these categories: zero, one, two, few, many, other. Here's what Polish needs:

{
  "messageCount": {
    "one": "{count} wiadomość",
    "few": "{count} wiadomości",
    "many": "{count} wiadomości",
    "other": "{count} wiadomości"
  }
}

Wait, few and many have the same translation here? Sometimes yes, sometimes no. The point is the _rules for which category a number falls into_ differ. In Polish:

  • 1 = one
  • 2-4 = few
  • 5-21 = many
  • 22-24 = few again
The Intl.PluralRules API handles this correctly:
const pr = new Intl.PluralRules("pl-PL");
pr.select(1); // "one"
pr.select(3); // "few"
pr.select(5); // "many"
pr.select(22); // "few"

If your translation files don't have slots for all the plural categories a language needs, add them now. ICU MessageFormat handles this well:

{count, plural,
  one {# wiadomość}
  few {# wiadomości}
  many {# wiadomości}
  other {# wiadomości}
}

RTL is not just direction: rtl

Setting dir="rtl" on your HTML element is step one of about fifteen. Here's what actually breaks:

Padding and margins. padding-left: 16px stays on the left in RTL mode. You need CSS logical properties:

/ Bad /
.sidebar {
  padding-left: 16px;
  margin-right: 8px;
}

/ Good / .sidebar { padding-inline-start: 16px; margin-inline-end: 8px; }

Icons with directional meaning. A "back" arrow pointing left makes no sense in RTL. You need to flip arrows, progress indicators, and anything implying direction. But don't flip everything — a checkmark stays a checkmark.

Mixed content. An Arabic sentence containing an English brand name or a code snippet creates bidirectional text runs. The Unicode Bidirectional Algorithm handles most cases, but you'll still hit edge cases with parentheses, URLs, and numbers in the middle of RTL text. Use tags around user-generated content and embedded LTR strings.

Flexbox and Grid. These actually handle RTL well if you use logical properties. justify-content: flex-start respects direction. But left and right in position: absolute don't. Use inset-inline-start instead.

Testing. You need to actually render your app in an RTL locale. Chrome DevTools lets you force dir="rtl" on the document, but that won't catch issues with hardcoded pixel offsets in JavaScript.

Your error messages are probably hardcoded

I've audited dozens of codebases for i18n readiness. The number one blind spot: error messages.

throw new Error("Invalid email address");
if (!user) {
  return res.status(404).json({ message: "User not found" });
}
toast.error("Something went wrong. Please try again.");

These strings never go through the translation pipeline because they live in business logic, not UI components. When you're extracting strings, grep for:

  • throw new Error(
  • toast.error( / toast.success(
  • console.warn( (if user-facing)
  • Any string literal in API response bodies that reaches the UI
  • Validation messages in form libraries
The pattern I recommend: error codes in the backend, translated messages in the frontend.
// Backend
return res.status(404).json({ code: "USER_NOT_FOUND" });

// Frontend const message = t(errors.${response.code}); toast.error(message);

Date, number, and currency formatting

new Date().toLocaleDateString() is a start, but it uses the browser's locale, which may not match the user's preferred language. Always pass an explicit locale:

new Intl.DateTimeFormat("de-DE", {
  year: "numeric",
  month: "long",
  day: "numeric",
}).format(date);
// "14. April 2026"

Numbers trip people up too. In Germany, 1.234,56 means one thousand two hundred thirty-four point fifty-six. In the US, that's 1,234.56. The decimal and thousands separators are swapped. Use Intl.NumberFormat, not string manipulation.

Currency is worse. You can't just swap the symbol — the position changes ($100 vs 100 € vs 100€), the number of decimal places varies (JPY has zero), and some currencies need special formatting rules.

Don't assume text length

German text is typically 30% longer than English. Finnish can be even longer. Chinese and Japanese are usually shorter. Your UI needs to handle this:

  • Buttons should not have fixed widths
  • Table columns need flexible sizing or truncation with tooltips
  • Mobile layouts need extra testing — a label that fits on one line in English might wrap to three in German

Hardcoded sort order

Array.sort() uses Unicode code points by default, which produces incorrect alphabetical order in most non-English locales. Use Intl.Collator:

const collator = new Intl.Collator("sv-SE");
["ö", "a", "å", "ä"].sort(collator.compare);
// ['a', 'ä', 'å', 'ö'] — correct Swedish order

In Swedish, ö comes after z, not near o. Getting sort order wrong makes search results and lists feel broken even if everything else is translated perfectly.

The practical workflow

Here's the order I recommend for retrofitting i18n into an existing codebase:

  • Wrap all UI strings in translation calls. This is mechanical work — a linter rule can catch raw string literals in JSX.
  • Fix error messages with the code-based pattern above.
  • Switch to CSS logical properties. Find-and-replace left/right with inline-start/inline-end.
  • Add plural rules using ICU MessageFormat for any string that includes a count.
  • Audit date/number/currency formatting — replace raw toLocaleString calls with explicit locale parameters.
  • Test with a pseudolocale that makes strings 40% longer and includes accented characters. This catches layout issues without needing real translations.
  • Get actual translations. This is where a translation API like auto18n fits — pipe your extracted strings through it to get a working baseline, then have native speakers review the output for critical user flows.
  • The difference between an app that "supports i18n" and one that actually works well in other languages comes down to these details. None of them are individually hard, but skipping any one of them produces a noticeably broken experience for users in affected locales.