Almost every localization project we are called into has the same shape. Someone has decided the product should be available in five more languages, a budget has been approved, translators have been found — and then the work stalls for six weeks while engineers discover that the software cannot actually be translated. Not "is not translated yet". Cannot be. The text is welded to the code.
Internationalization is the work that makes translation possible. It happens once, in the codebase, before a translator sees anything. Localization is what happens afterwards, repeatedly, for each locale. Conflating the two is the single most expensive mistake in this field, and it is almost universal.
This article is the checklist we run through during a readiness audit. Work through it on your own codebase and you will know, fairly precisely, how far you are from being translatable.
Rule one: text lives outside the code
Every user-visible string has to be addressable by a key, loaded at runtime from a catalogue, and replaceable without recompiling logic. This sounds obvious and is violated constantly — usually not in the obvious places but in the ones nobody thought of as "text": validation messages, log lines that end up in a support dialog, enum values rendered directly, `alt` attributes, email templates, PDF headers, chart axis labels.
The practical test is simple. Search your codebase for quoted strings that reach a user and are not wrapped in a translation call. If your linter cannot answer that question, that is itself a finding — you will need that lint rule in the section on guardrails below.
# Not translatable if user.credits < 1: show_error("You do not have enough credits.") # Translatable if user.credits < 1: show_error(_("You do not have enough credits."))
Note what the second version does not do: it does not replace the English with an opaque key like ERR_CREDITS_LOW. Using the source text as the key keeps the code readable, gives translators a real sentence to work from, and means an untranslated locale degrades to English rather than to a database identifier. Key-based systems have their place — very large catalogues, or products where the English itself changes often — but they need tooling and discipline that source-as-key does not.
Rule two: never build a sentence from parts
This is the problem we find most often, and the one developers defend most vigorously. It looks efficient and it destroys translatability:
// Three fragments, assembled at runtime message = _("You have") + " " + count + " " + _("new messages");
The translator receives two fragments with no idea how they will be joined. Word order differs between languages — German pushes verbs to the end, Japanese puts the object first, Arabic runs the other way entirely. Agreement rules mean the noun's form may depend on the number in ways the fragment cannot express. And you have made it impossible to write a correct plural rule, because the plural applies to a phrase that does not exist as a unit anywhere in your code.
The fix is to make the whole sentence one message with named arguments:
// One message, one translator decision message = t("You have {count, plural, one {# new message} other {# new messages}}", { count: count });
Now the translator owns the entire structure. A Polish translator can supply the three plural forms Polish requires; an Arabic translator can supply six. Neither has to fight your string concatenation to do it.
The rule of thumb we give teams
If a translator cannot rewrite the sentence completely — reordering every element, changing the punctuation, splitting or merging clauses — the string is not properly internationalized. Concatenation, `printf` fragments and template partials all fail this test.
Rule three: plurals are not "if n == 1"
English has two plural forms. That is not a universal. The CLDR plural rules define up to six categories — zero, one, two, few, many, other — and which ones a language uses, and for which numbers, varies enormously.
| Language | Forms | Example boundaries |
|---|---|---|
| Japanese, Chinese, Vietnamese | 1 | No grammatical number distinction at all |
| English, German, Dutch | 2 | 1 vs everything else |
| French, Portuguese (BR) | 2 | 0 and 1 both singular |
| Czech, Slovak | 3 | 1 / 2–4 / 5+ |
| Polish, Russian, Ukrainian | 3–4 | 1, 21, 31… / 2–4, 22–24… / 5–20, 25–30… |
| Arabic | 6 | 0 / 1 / 2 / 3–10 / 11–99 / 100+ |
You do not need to know these rules. You need to use a message format that lets translators express them — ICU MessageFormat, gettext's ngettext with a correct Plural-Forms header, or Fluent — and you need to never, ever write count === 1 ? singular : plural in application code.
A related trap: "0 items" is not always the plural form. Several languages treat zero specially, and many interfaces read better with a dedicated empty-state message anyway. ICU's =0 exact match handles this cleanly:
{count, plural,
=0 {No files selected}
one {# file selected}
other {# files selected}}
Rule four: grammatical gender needs a select
"Alex shared their document with you" is a sentence English can dodge. Many languages cannot: the verb form, the adjective, or the possessive may all inflect for the gender of the subject. If your application knows the gender — because the user told you — pass it to the message and let the translator branch on it.
{gender, select,
female {{name} updated her profile}
male {{name} updated his profile}
other {{name} updated their profile}}
If your application does not know, do not guess. Write the source string so that a gender-neutral formulation is possible, and warn translators in the string comment that the subject's gender is unknown. Every language has a workaround; translators need to be told that they need one.
Rule five: never format data by hand
Dates, times, numbers, currencies, percentages, units, lists, relative times and personal names all have locale-specific formatting rules, and all of them are already solved in CLDR. Hand-rolled formatting is a reliable source of embarrassing bugs.
- Dates —
03/04/2026is 3 April in most of the world and 4 March in the United States. Use a locale-aware formatter and a named style (short,medium,long) rather than a pattern string. - Decimal separators — a comma in most of Europe, a period in English-speaking countries, and in some locales a distinct group separator that is not a comma at all.
- Digit grouping — not always in threes. Indian numbering groups as 12,34,567.
- Currency — symbol position, spacing and decimal count all vary; the Japanese yen has no minor unit, several currencies have three decimal places.
- Lists — "A, B and C" versus "A, B, and C" versus languages using entirely different conjunction placement. Use
Intl.ListFormator its equivalent. - Names — do not assume given name plus family name in that order, or that everyone has two. Store a display name and one sortable field if you truly need sorting.
A cheap, high-value fix
Add a lint rule that flags toFixed(), manual date string building and hard-coded currency symbols in view code. In one client codebase this single rule surfaced 140 formatting bugs that no translator would ever have caught, because none of them were in strings.
Rule six: treat text as text, not as bytes
Unicode correctness is where subtle, hard-to-reproduce bugs live. A few rules that head off most of them:
- Truncation must be grapheme-aware. Cutting a string at "20 characters" can split an emoji, a Devanagari cluster or a combining accent, producing mojibake or a crash. Use a grapheme segmenter.
- Length limits in code points are not length limits on screen. Chinese text is far shorter than its English source; German is routinely 30–40% longer. Design for both.
- Case conversion is locale-dependent. Turkish dotless ı uppercases to I while dotted i uppercases to İ; a locale-blind
toUpperCase()corrupts Turkish text. German ß is another classic. - Sorting must use a collator, not byte comparison. Swedish sorts ö after z; German sorts it with o. Both are correct, in their own locale.
- Normalization matters for comparison and search. The same visible character can be a single code point or a base plus combining mark; normalise to NFC before comparing user input.
- Search and matching should be accent-insensitive where the locale expects it, and must not be where it does not.
Rule seven: the layout must survive the text
Fixed-width buttons, single-line labels and tight table columns are all bets that the translation will be roughly as long as the source. That bet loses. As a working guide, expect German and Finnish to run 30–40% longer than English, Russian around 20%, and Chinese or Japanese to run considerably shorter — which creates its own layout problems when a button suddenly looks empty.
Two habits catch nearly all of this before it reaches a user:
- Pseudo-localization. Build a fake locale that pads every string by 40% and swaps characters for accented look-alikes:
[!!! Ŝàvé çĥàñĝéŝ !!!]. Anything that clips, wraps badly or stays plain English is now visible at a glance, and you can run it in CI on screenshots. - Logical CSS properties. Write
margin-inline-startrather thanmargin-left,padding-blockrather thanpadding-top/bottom. Your layout then mirrors correctly for right-to-left locales for free. We cover this in depth in the RTL and bidirectional layout guide.
Rule eight: ship context with the strings
A translator working from a bare list of strings is guessing, and guesses show up as bugs. "Open" is a verb on a button and an adjective in a status column, and many languages translate those differently. Give every ambiguous string a comment, and give the catalogue a way to disambiguate identical source strings that mean different things.
/* Translators: verb on a button that opens the selected file */ label = _("Open"); /* Translators: adjective, describes a ticket that is not yet closed */ status = pgettext("ticket status", "Open");
Screenshots are better still. If your translation platform supports attaching a screen capture to a string, wire it into CI — a translator who can see where the text appears makes an order of magnitude fewer contextual errors.
Rule nine: make regressions impossible, not merely discouraged
Every rule above will be broken by a well-meaning contributor within three months unless a machine enforces it. The guardrails we install on every engagement:
- A lint rule rejecting user-visible string literals outside translation calls.
- A CI check that the extracted catalogue is up to date — extraction runs, and the build fails if the result differs from what is committed.
- Placeholder validation: every translation must contain exactly the placeholders the source does. This one check catches a large share of real, user-facing translation bugs.
- A pseudo-locale screenshot job on key screens, with a human looking at the diff.
- A rule banning
toUpperCase,toFixedand manual date assembly in view layers.
The goal is not a codebase that is internationalized today. It is a codebase where the next hundred pull requests stay internationalized without anyone having to remember these rules.
— the line we put at the top of every audit report
A readiness audit you can run this week
Score yourself honestly. Each item is a yes or a no.
- Can you produce a complete catalogue of user-visible strings with one command?
- Does that command run in CI, and does the build fail when the catalogue is stale?
- Are there zero string concatenations producing user-visible sentences?
- Does every countable message use a real plural format with named arguments?
- Is every date, number and currency formatted by a locale-aware library?
- Does the UI render correctly in a pseudo-locale with 40% expansion?
- Does the layout mirror correctly with
dir="rtl"? - Do ambiguous strings carry translator comments?
- Is there a glossary, and does anything check that translations follow it?
- Can a translator see where a string appears without asking a developer?
Eight or more yeses and translation will go smoothly. Five to seven and you have a few weeks of engineering to do first — worth doing, and cheap compared with the alternative. Four or fewer and translating now would be actively wasteful: you would be paying for work that has to be redone the moment the underlying problems are fixed.
That last case is the common one, and it is not a failure. Every codebase that was not internationalized from the first commit ends up here. The mistake is not being in that position; the mistake is starting to translate anyway.
Working on something like this?
We do this for open source projects for a living, and for free when the project cannot pay. Tell us about yours — the first audit costs nothing.