Guide

Internationalization (i18n) explained

Harbor Commerce launched a German storefront by translating marketing copy in a spreadsheet while leaving checkout labels, error messages, and date formats in English. Cart abandonment among DE visitors jumped 22% in the first month. Product analytics showed users bouncing on the payment step when “MM/DD/YYYY” appeared beside a German address form and when plural strings like “1 items” rendered incorrectly. The fix was not “hire more translators” alone — it was proper internationalization (i18n): externalizing every user-facing string, formatting dates and currency with locale-aware APIs, supporting right-to-left layouts where needed, and wiring hreflang SEO so Google served the right language variant. Within one quarter, DE bounce rate on checkout halved and conversion recovered to within 3% of the U.S. baseline. i18n is the engineering foundation that makes localization (l10n) — the actual translation and cultural adaptation — possible without rewriting the app per country. This guide covers terminology, locale identifiers, message catalogs and ICU plurals, Intl formatting, RTL layout concerns, server vs client rendering strategies, translation workflows, a Harbor Commerce refactor worked example, an approach decision table, common pitfalls, and a production checklist.

i18n, l10n, and g11n — what each term means

Teams conflate these acronyms constantly. Precision matters when scoping work:

  • Internationalization (i18n): designing and building software so it can be localized — externalized strings, locale-aware formatting, Unicode throughout, layout that tolerates text expansion and RTL.
  • Localization (l10n): adapting a product for a specific market — translating strings, choosing currency and units, complying with local regulations, swapping imagery or examples.
  • Globalization (g11n): the business and process umbrella — market selection, legal, support hours, payment methods, and the pipeline that connects i18n engineering to l10n delivery.

You cannot localize well without i18n first. Hard-coded English in JSX, concatenated strings ("Hello " + name), and toFixed(2) for every currency are i18n failures that no translation agency can fix post hoc.

Locales and BCP 47 identifiers

A locale is a language tag that may include region, script, and variant. BCP 47 is the standard: en (English), en-US (U.S. English), de-DE (German as used in Germany), zh-Hans-CN (Simplified Chinese in China). Use full tags when formatting behavior differs by region — en-GB formats dates as 10/06/2026 while en-US uses 06/10/2026.

Store the user’s locale preference separately from language for content. A Swiss user might want French UI (fr-CH) but browse product descriptions pulled from a CMS in German. Avoid IP-geolocation alone for locale — VPNs, travelers, and expatriates break that assumption. Accept Accept-Language as a hint, let users override in settings, and persist the choice in a cookie or profile field.

For URL structure, common patterns are subdirectories (/de/products), subdomains (de.example.com), or query parameters (?lang=de). Subdirectories are usually best for SEO when paired with hreflang; query params are weakest for indexing. Frameworks like Next.js support locale-prefixed routing out of the box.

Externalizing strings and message catalogs

Every user-visible string belongs in a message catalog — JSON, YAML, or PO files keyed by stable identifiers, not English text. Components call t('checkout.submit') instead of embedding literals. Keys should describe purpose, not content: cart.empty.title not your_cart_is_empty.

Never concatenate translated fragments. Word order differs across languages (“red house” vs “maison rouge”), and gendered articles make split strings untranslatable. Use ICU MessageFormat with named placeholders:

{username} added {count, plural, one {# item} other {# items}} to the cart.

Libraries like formatjs, react-intl, next-intl, and i18next implement ICU plurals, select (gender), and nested placeholders. Load only the locale bundle the user needs — shipping all 40 languages in one JavaScript chunk wastes bandwidth. Code-split per locale or fetch catalogs from a CDN at runtime.

Formatting dates, numbers, and currency

Do not hand-format dates or currencies. The browser’s Intl API (and server equivalents in Node, Java, .NET) respects locale rules:

  • new Intl.DateTimeFormat('de-DE').format(date) → 10.6.2026
  • new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(1234.5) → 1 234,50 €

Store instants in UTC (ISO 8601 strings or Unix timestamps) and convert to the user’s time zone only at display time. Our date, time and timezones guide covers DST pitfalls that break checkout cutoffs and subscription renewals. Currency display is not currency settlement — show EUR prices to German users but charge through a payment processor that handles FX and tax separately.

Relative time (“3 hours ago”) needs Intl.RelativeTimeFormat or a library that updates on an interval. Compact notation (1.2M vs 1,200,000) varies by locale. Unit formatting (Intl.NumberFormat with unit: 'kilometer') handles metric vs imperial when product specs differ by market.

RTL layouts, text expansion, and typography

Arabic, Hebrew, Persian, and Urdu read right-to-left. Set dir="rtl" on the <html> element (or a locale wrapper) and use logical CSS properties — margin-inline-start instead of margin-left — so mirrored layouts work without duplicate stylesheets. Icons with direction (arrows, back buttons) may need flipping; numbers and Latin brand names often stay LTR inside RTL paragraphs (unicode-bidi rules apply).

German translations average 30% longer than English; Finnish can run 40% longer. Buttons, nav items, and table columns must tolerate expansion without truncation or overflow. Avoid fixed-width labels. Test with pseudo-localization — a fake locale that wraps strings in brackets and lengthens them — to surface layout breaks before translators deliver files.

Web fonts must include glyphs for target scripts. A Latin-only Inter subset will render tofu boxes for Japanese or Cyrillic. Subset per script or load Noto families for CJK coverage. Line height and font size that work in English may need adjustment for Thai or Devanagari diacritics.

Server-side vs client-side i18n

Server-rendered i18n (SSR/SSG) sends translated HTML on first paint — best for SEO, social previews, and users on slow devices. The server reads the locale from the URL or cookie, loads the message catalog, and renders strings into the template before streaming bytes. Hydration must match: if the client bundle loads a different locale, React will warn about text mismatches.

Client-side i18n fetches catalogs after JavaScript loads. Simpler for SPAs but worse for crawlers unless you prerender per locale. Hybrid patterns — SSG one HTML file per locale, or edge middleware that rewrites locale — are common in SSR/SSG architectures.

User-generated content (reviews, comments) is usually not auto-translated; machine translation APIs can offer optional “translate this review” buttons. Error messages from third-party APIs arrive in English — map known codes to localized strings rather than passing raw API text to users.

SEO: hreflang, canonicals, and duplicate content

Multilingual sites risk duplicate-content penalties if Google sees the same product page in five languages without signals. Add link rel="alternate" hreflang="xx" tags in each locale’s <head>, plus an x-default entry for language selectors. Each locale URL should canonicalize to itself, not to the English parent. XML sitemaps can list hreflang alternates per URL.

Translate <title>, meta description, and Open Graph tags — not just body copy. Structured data (Product, Article) should use locale-appropriate priceCurrency and inLanguage fields. Keep slug strategy consistent: either translate slugs (/de/produkte/widget) for local keywords or keep English slugs with localized titles — pick one pattern per site and document it.

Translation workflow and quality

Engineering delivers localization kits: message catalogs, screenshots with string IDs, character limits for buttons, and glossary terms (brand names that must not be translated). Translators work in TMS platforms (Phrase, Lokalise, Crowdin) that sync back to JSON via CI. Never email Excel files without context — ambiguous strings produce nonsense (“Book” as noun vs verb).

Run linguistic QA in staging with native speakers before launch. Automated checks catch missing keys, stale English fallbacks, and broken ICU syntax. Snapshot tests per locale catch regressions when developers add keys without translations. Version catalogs alongside code — a deploy with new English keys and empty German values ships broken UI.

Worked example: Harbor Commerce DE storefront refactor

Harbor Commerce’s U.S. React storefront had 1,400 hard-coded strings, moment.js with a forced en locale, and prices rendered as ${price.toFixed(2)}. The German launch exposed:

  1. Checkout date pickers showing U.S. month-first order.
  2. Shipping estimates labeled “business days” without German public-holiday logic.
  3. Plural bugs: {count} Artikel missing ICU rules for 0 and 1.
  4. SEO: German ads pointed to English URLs; Quality Score suffered.

The refactor sprint:

  • Extracted strings to messages/en.json and messages/de.json with next-intl.
  • Replaced moment with Intl.DateTimeFormat and stored order timestamps in UTC.
  • Added /de/* routes with middleware locale detection and hreflang tags on every product page.
  • Introduced pseudo-locale en-XA in CI to catch truncation.
  • Connected Phrase TMS so marketing could update copy without PRs for typos.

Result: DE checkout bounce fell from 41% to 19%; support tickets about “wrong language” dropped 87%; German organic traffic grew 34% within six months as hreflang indexing stabilized.

Approach decision table

Scenario Recommended approach Why
Marketing site, 3–5 locales, SEO-critical SSG per locale + hreflang + subdirectory URLs Fast first paint, full crawlability, clear URL signals
Logged-in SaaS dashboard Client i18n with user profile locale + lazy-loaded catalogs SEO irrelevant; reduce initial bundle via code-splitting
E-commerce with CMS product data Server i18n for chrome; CMS fields per locale for content Separates UI strings from translatable product attributes
RTL market launch Logical CSS + dir attribute + RTL screenshot QA Physical left/right rules break in RTL without logical properties
Rapid MVP, one extra language JSON catalogs + Intl; skip TMS until >2 locales TMS overhead not justified for a single translation pass
10+ locales, frequent copy changes TMS + CI sync + pseudo-locale regression tests Manual JSON merges do not scale; automation prevents drift

Common pitfalls

  • Translating in the UI layer only. Emails, PDF invoices, push notifications, and error logs need the same catalogs.
  • Using machine translation for legal copy. Terms of service and privacy policies need professional legal translation per jurisdiction.
  • Ignoring plural, gender, and case rules. English “one/other” plural categories are insufficient for Arabic (six forms) or Polish (three).
  • Sorting strings alphabetically for display. Use Intl.Collator — Swedish sorts “å” after “z”, not after “a”.
  • Hard-coding currency symbols. $ means USD, AUD, CAD, and more — always pair symbol with currency code in data.
  • Locale fallback chains that silently show English. Missing keys should surface in QA, not production.
  • Embedding text in images. Images cannot be translated without redesign; use HTML text or SVG with accessible labels.
  • Skipping RTL until “later.” Retrofitting logical properties across 200 components costs more than building RTL-ready from day one.

Production checklist

  • All user-visible strings externalized with stable keys — zero literals in components.
  • ICU MessageFormat for plurals, selects, and interpolated sentences.
  • Intl (or equivalent) for dates, numbers, currency, and lists.
  • Timestamps stored UTC; time zones applied at display per user preference.
  • Locale persisted (cookie/profile) with explicit user override in settings.
  • Logical CSS properties; dir attribute set per locale for RTL.
  • hreflang alternates and self-referencing canonicals on every indexed locale URL.
  • Translated <title>, meta description, and OG tags per locale.
  • Per-locale sitemap entries or hreflang blocks in sitemap.xml.
  • Pseudo-localization job in CI; missing-key linter on pull requests.
  • TMS or documented process for translator handoff with screenshots and glossaries.
  • Native-speaker QA on staging before each locale launch.

Key takeaways

  • i18n is engineering; l10n is translation — build locale-ready architecture before hiring translators.
  • BCP 47 locale tags drive formatting — never assume one English or one Spanish for all regions.
  • ICU MessageFormat and Intl APIs handle plurals and formats browsers already know.
  • Server-rendered translated HTML wins for SEO; client i18n needs prerender or accepts crawl trade-offs.
  • hreflang + per-locale canonicals prevent duplicate-content confusion in multilingual sites.

Related reading