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 =
fewagain
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
// 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:
left/right with inline-start/inline-end.toLocaleString calls with explicit locale parameters.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.