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.2026new 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:
- Checkout date pickers showing U.S. month-first order.
- Shipping estimates labeled “business days” without German public-holiday logic.
- Plural bugs:
{count} Artikelmissing ICU rules for 0 and 1. - SEO: German ads pointed to English URLs; Quality Score suffered.
The refactor sprint:
- Extracted strings to
messages/en.jsonandmessages/de.jsonwithnext-intl. - Replaced
momentwithIntl.DateTimeFormatand stored order timestamps in UTC. - Added
/de/*routes with middleware locale detection and hreflang tags on every product page. - Introduced pseudo-locale
en-XAin 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;
dirattribute 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
- Responsive web design explained — fluid layouts that tolerate text expansion and RTL mirroring
- Date, time and timezones explained — UTC storage, DST gaps, and locale-aware display
- Next.js fundamentals explained — locale routing, middleware, and SSR patterns for multilingual apps
- SEO fundamentals explained — hreflang, canonicals, and international search indexing