AI Hardcodes English — Every String Is a Translation Debt
AI generates English strings directly in code: <h1>Welcome back, {name}</h1>, toast("Order placed successfully"), throw new Error("Invalid email address"). Every hardcoded string is a translation debt — when internationalization is needed (and it always is eventually), every string must be found, extracted, keyed, and translated. In a codebase with 5,000 hardcoded strings, this is months of work.
The fix is simple but must be enforced from day one: never render a user-facing string directly. All strings go through a translation function: t("welcome.back", { name }). The translation function looks up the string in a message catalog for the current locale. English is just another locale — the default one.
These rules cover: message catalogs and translation functions, date/number formatting with Intl, pluralization, RTL layout support, and the translation workflow. They apply to any framework: React (react-intl, next-intl), Vue (vue-i18n), Angular (ngx-translate), and backend (i18next, gettext).
Rule 1: All User-Facing Strings in Message Catalogs
The rule: 'Never hardcode user-facing strings. All text visible to users goes through a translation function: t("dashboard.title") returns the string for the current locale. Define strings in message catalog files: en.json: { "dashboard": { "title": "Dashboard", "welcome": "Welcome back, {name}" } }. The translation function receives a key and interpolation values — never a raw English string.'
For what to extract: 'Extract: page titles, button labels, form labels, error messages, toast notifications, email subjects, placeholder text, and alt text. Do NOT extract: log messages (developers read these in English), code comments, variable names, or API error codes (machines read these). The test: will a user see this text? If yes, extract it.'
For namespacing: 'Organize keys by feature or page: auth.login.title, auth.login.email_label, dashboard.stats.total_users. Namespacing prevents: key collisions (two features with "title"), and makes it easy to find all strings for one page. Use dot notation for nesting: t("auth.login.title") maps to { auth: { login: { title: "..." } } }.'
- t('key', { values }) for all user-facing text — never hardcoded strings
- Message catalogs: en.json, fr.json, ja.json — one per locale
- Namespace by feature: auth.login.title, dashboard.stats.total — no collisions
- Extract: titles, labels, errors, toasts, placeholders, alt text
- Do NOT extract: logs, comments, variable names, API codes
Every hardcoded string must be: found (grep the codebase), extracted (create a key), keyed (add to catalog), and translated (per locale). With 5,000 strings, this takes months. Extracting from day one takes seconds per string. The ROI is infinite.
Rule 2: Intl API for Dates, Numbers, and Currencies
The rule: 'Use the Intl API for all date, number, and currency formatting — never manual formatting with template literals or hardcoded patterns. new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(date) outputs: "March 28, 2026" (en-US), "28 mars 2026" (fr-FR), "2026年3月28日" (ja-JP). new Intl.NumberFormat(locale, { style: "currency", currency: "USD" }).format(99.99) outputs: "$99.99" (en-US), "99,99 $US" (fr-FR).'
For relative time: 'Use Intl.RelativeTimeFormat for relative dates: new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(-1, "day") outputs: "yesterday" (en), "hier" (fr). Never hardcode "X days ago" — the phrasing, number placement, and time units vary dramatically across languages.'
AI generates date.toLocaleDateString() (partially correct but not configurable) or moment(date).format("MMMM D, YYYY") (English-only format string). The Intl API is built into every browser and Node.js — no library needed, locale-aware by default, and correctly handles every language's formatting rules.
Intl.DateTimeFormat, Intl.NumberFormat, Intl.RelativeTimeFormat — all built into every browser and Node.js. No library needed. Locale-aware by default. Correctly formats dates, numbers, and currencies for 500+ locales.
Rule 3: Pluralization with ICU Message Format
The rule: 'Use ICU MessageFormat for pluralization — never if/else for singular/plural. English has 2 forms: 1 item, 2 items. Arabic has 6 forms. Russian has 3. Polish has 4. ICU handles all of them: "{count, plural, one {# item} other {# items}}" in English. "{count, plural, one {# элемент} few {# элемента} many {# элементов} other {# элементов}}" in Russian. The translator fills in the forms — your code uses the same key.'
For gender: 'ICU SelectFormat handles gender-dependent text: "{gender, select, male {He joined} female {She joined} other {They joined}}" — one key, three translations. Languages with grammatical gender (French, German, Arabic) need this for correct grammar. English often uses "they" — but other languages cannot.'
AI generates: count === 1 ? "1 item" : `${count} items` — works for English, wrong for every language with more than 2 plural forms. ICU MessageFormat is the international standard for pluralization — supported by react-intl, next-intl, vue-i18n, i18next (with plugin), and every serious i18n library.
- ICU MessageFormat for pluralization — not if/else for singular/plural
- English: 2 forms (one, other) — Arabic: 6 — Russian: 3 — Polish: 4
- {count, plural, one {# item} other {# items}} — one key, all forms
- SelectFormat for gender: {gender, select, male {He} female {She} other {They}}
- Translator fills in locale-specific forms — your code is locale-agnostic
English: 1 item, 2 items (2 forms). Arabic: zero, one, two, few, many, other (6 forms). if/else for singular/plural breaks for every non-English language. ICU MessageFormat handles all plural forms from one key.
Rule 4: RTL Layout Support
The rule: 'Support right-to-left (RTL) languages (Arabic, Hebrew, Persian, Urdu) from the start — retrofitting RTL is extremely expensive. Set dir="rtl" on <html> based on the current locale. Use CSS logical properties: margin-inline-start instead of margin-left, padding-inline-end instead of padding-right, inset-inline-start instead of left. Logical properties automatically flip for RTL — physical properties do not.'
For Tailwind: 'Use Tailwind RTL plugin or logical property utilities: ms-4 (margin-inline-start: 1rem) instead of ml-4 (margin-left: 1rem). Or use the rtl: and ltr: variants: rtl:text-right ltr:text-left. Enable RTL by setting dir="rtl" on the root element — Tailwind handles the rest with variants.'
For icons and images: 'Flip directional icons in RTL: arrows, navigation chevrons, and progress indicators should point the opposite direction. Do NOT flip: non-directional icons (search magnifying glass, settings gear), logos, and images of real objects. Use CSS transform: scaleX(-1) for icons that should flip — or provide RTL-specific icon variants.'
Rule 5: Translation Workflow and Tools
The rule: 'Use a translation management platform: Crowdin, Lokalise, Phrase, or Transifex. Developers push message catalog files (en.json) to the platform. Translators translate in the platform UI. Translations are pulled back as locale files (fr.json, ja.json). CI/CD pulls translations before build — the app ships with all locales. Never email JSON files to translators — it is error-prone and unscalable.'
For the developer workflow: 'Add new strings to en.json with descriptive keys and context comments. Push to the translation platform (git integration or CLI). Translators are notified of new strings. Translations are reviewed and approved. CI pulls the latest translations before build. Missing translations fall back to English — the app never shows a raw translation key to users.'
For pseudo-localization: 'Use pseudo-locale (accented English: Wéĺçömé Bàçk) in development to catch: hardcoded strings (they are not accented), text overflow (pseudo-locale is ~30% longer than English), and encoding issues (special characters break rendering). Run the app in pseudo-locale before every release — if something looks normal in pseudo-locale, it is hardcoded and not extracted.'
- Translation platform: Crowdin/Lokalise/Phrase — never email JSON files
- Developer: add to en.json → push → translator works → CI pulls translations
- Missing translations fall back to English — never show raw keys
- Pseudo-locale in dev: catches hardcoded strings, overflow, encoding issues
- Context comments for translators: // This appears on the checkout page
Complete i18n Rules Template
Consolidated rules for internationalization and localization.
- t('key', { values }) for all user-facing text — never hardcoded strings
- Message catalogs per locale: en.json, fr.json — namespace by feature
- Intl API for dates, numbers, currencies — never manual format strings
- ICU MessageFormat for pluralization — not if/else for singular/plural
- CSS logical properties: margin-inline-start not margin-left — RTL-ready
- dir='rtl' on <html> — flip directional icons, not logos or real objects
- Translation platform (Crowdin/Lokalise) — CI pulls before build
- Pseudo-locale in dev — catches hardcoded strings and text overflow