Internationalization Isn't Translation: What Actually Breaks

Ask most developers what internationalization means and you'll get the same answer: instead of writing "Save" in your JSX, you look up a key in a dictionary and get "Save" or "Enregistrer" depending on the user's language. And they're right — that is part of it. It's also the easy 10%, and it's the part that makes people underestimate the whole thing.
Because a translated string is not a translated interface. The moment you actually ship to another language, the interesting failures show up, and none of them are about the words. A date renders as 7/18/2026 to an American and 18/07/2026 to nearly everyone else. A price needs a different currency symbol, in a different position, with a comma where you had a period. "1 item" versus "5 items" is trivial in English and a genuine grammar problem in languages with six plural forms. And in Arabic or Hebrew the entire layout flips direction. None of that is a dictionary lookup.
So this article is about the 90% — what internationalizing a React app actually involves once you stop thinking of it as translation and start thinking of it as formatting and loading, which is what it really is.
The Reframe: It's Formatting, Not Words
Here's the shift that made i18n click for me. Stop picturing a dictionary. Picture a formatting engine.
The hardest parts of internationalization aren't the strings you wrote — they're the values you render. A date is a value that has to be formatted for a locale. A number is a value. A currency amount is a value. A count that decides between "item" and "items" is a value running through a locale's plural rules. The English strings are static and easy; the dynamic values are where every locale disagrees, and where a naive app quietly produces wrong output that looks fine to you and broken to them.
Translation handles the words you wrote. Internationalization handles the values you render. The second one is the hard part, and it's the part people skip.
The good news is you don't implement any of this yourself. The browser ships a full formatting engine — the Intl API — and the React libraries are wrappers that make it ergonomic.
Formatting Values the Right Way
The instinct, when you need a date or a price in the UI, is to build the string by hand — grab the month, slap it together, append a currency symbol. That instinct is exactly the bug, because the format itself is locale-dependent, not just the numbers inside it.
The Intl API already knows every locale's rules. You give it a value and a locale; it gives you correct output:
// Dates: same instant, formatted for the user's locale. new Intl.DateTimeFormat('en-US').format(date); // "7/18/2026" new Intl.DateTimeFormat('de-DE').format(date); // "18.7.2026" // Currency: symbol, position, and separators all differ by locale. new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }) .format(1234.5); // "$1,234.50" new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }) .format(1234.5); // "1.234,50 €"
Look at what you're not deciding: where the currency symbol goes, whether the thousands separator is a comma or a period, the order of day and month. You hand over the value and the locale and get correct output. Building any of that by hand isn't just more code — it's code that's confidently wrong in locales you don't personally read.
Then there's pluralization, which English makes look trivial and other languages do not. "1 item" / "2 items" is a two-case rule; Polish and Arabic have far more, and a hardcoded count === 1 ? 'item' : 'items' is simply broken there. This is why the string libraries use the ICU message format, which encodes plural rules per locale:
{count, plural, one {# item} other {# items}}
You write the rule once; the library picks the right form for whatever locale is active, using the same Intl plural data. You are never in the business of knowing Polish grammar — you're in the business of not hardcoding English grammar.
The React Side: Loading, Not Bundling
Now the part that's specifically a React and architecture problem, and the one that separates a demo from a real app: you cannot ship every language in your bundle.
Here's the trap. You wire up react-i18next or FormatJS, it works, and to make it work you import all your translation files at the top. Now every user downloads English, French, German, Japanese, and Arabic just to see the one language they actually use. Your bundle balloons with text 90% of users will never read, and it gets worse with every locale you add. A feature meant to serve more users made the app slower for all of them.
The right shape is to treat translation data as something you load on demand, the same way you'd code-split a route. The active locale's messages load when needed; the rest stay on the server until someone actually switches:
// Load only the active locale's messages, not all of them. async function loadMessages(locale: string) { const messages = await import(`../locales/${locale}.json`); return messages.default; }
That dynamic import is the whole difference. A user on French downloads French. Switch to German and German loads then — a small, one-time fetch — instead of everyone paying for every language up front. The i18n libraries support this directly; the mistake is not the library, it's statically importing everything because the demo did.
This is the same instinct the whole series keeps returning to: the naive version loads everything eagerly, and the production version loads what's needed when it's needed. Translations are just data, and data that most users won't touch belongs behind a lazy load.
One More Thing: Direction
A short but real one, because it's the part that turns a "translated" app back into an untranslated-looking one. Some languages — Arabic, Hebrew, Persian — read right-to-left, and that's not a font setting, it's a layout inversion. Your sidebar moves to the other side, your icons mirror, your text aligns the other way.
The workable answer is to stop thinking in "left" and "right" and start thinking in "start" and "end." Modern CSS logical properties — margin-inline-start instead of margin-left, padding-inline-end instead of padding-right — flip automatically with the document direction. Set dir="rtl" on the root for RTL locales and a layout built on logical properties reorients itself. Build it on hardcoded left/right and you get to audit every component by hand. It's cheap if you do it from the start and expensive if you retrofit — which is a good reason to reach for logical properties by default even in a single-language app.
How I'd Approach It
Strip it to decisions:
- It's formatting, not translation. The strings are easy; the values — dates, numbers, currency, plurals — are where locales actually disagree.
- Never format values by hand. Use
Intl(via your i18n library). It already knows every locale's rules; you never do. - Use ICU message format for plurals. Don't hardcode English's two-case grammar into a multi-locale app.
- Load locales on demand, don't bundle them all. Dynamic-import the active language; leave the rest on the server.
- Build layout on logical properties.
start/end, notleft/right, so RTL is a setting and not a rewrite.
I used to think of i18n as a chore you tack on at the end — extract the strings, hand them to translators, done. That framing is why so many "internationalized" apps still show a German user an American date and an Arabic user a left-to-right layout. The strings were never the hard part. The hard part is that every locale formats values differently and reads in its own direction, and the app has to be built to bend to that instead of assuming the world works like the machine you developed on. Get the formatting and loading right and translation is the small, boring finish — which is exactly where it should sit.
Speaking of values that quietly differ everywhere and ruin your day when you get them wrong — the next article is about handling money in React: what taking a payment actually requires, and why the client should never be the thing that decides you got paid.
If you've got an app that "supports" multiple languages but still leaks the developer's locale — an English date here, a broken plural there — that's the formatting-not-translation gap. Tell me what's showing wrong and I'll point you at which piece is missing.