Skip to content

Chapter 30: Internationalization Is the Platform's Quiet Promise

The browser has, hidden in plain sight, one of the most capable internationalization libraries in any programming environment.

Most frontend developers never touch it. The default mental model — English is the source language, other languages are translations, dates and numbers are strings we format ourselves — is so embedded in working frontend culture that the platform’s actual internationalization capabilities go largely unused. Teams reach for npm packages (moment and later date-fns, intl-messageformat, react-intl, the long tail of locale-aware utilities) to do work the browser has been doing natively for years.

This chapter argues that internationalization, like accessibility (Chapter 29), is a platform commitment the application either honors or breaks. The platform has done substantial work to make the application’s job easier. The default frontend approach throws away most of it.

The Intl global has been part of JavaScript since ES2015, with steady additions in every annual release since. The namespace exposes a family of constructors for locale-aware operations:

Intl.NumberFormat
Intl.DateTimeFormat
Intl.RelativeTimeFormat
Intl.ListFormat
Intl.PluralRules
Intl.Collator
Intl.Segmenter
Intl.Locale
Intl.DisplayNames
Intl.DurationFormat

Each constructor takes a locale identifier (a BCP 47 string like "en-US", "de-CH", "ja-JP", "ar-EG") and an options object, and produces an object that can format or compare values according to the rules of that locale. The locale data — formatting rules, plural-form definitions, collation orders, calendar systems, number systems — comes from the Unicode Common Locale Data Repository (CLDR), maintained by the Unicode Consortium. The browser ships the CLDR data as part of its implementation.

This is significant. Every browser, by default, has detailed locale data for hundreds of locales. The data covers number formatting, date formatting, calendar conversions, plural rules, sorting rules, currency symbols, time-zone names, and dozens of other categories. The application can ask the browser to format anything for any locale, and the browser produces the correct output according to CLDR’s research and conventions.

For most i18n needs, this means no library required. The work the application would have outsourced to moment.js or Numeral.js or react-intl is built into the platform. Norbert Lindenberg’s original Intl API design at Mozilla in 2012, refined through years of TC39 work by Eemeli Aro and others, gave the language a substrate that most developers haven’t fully discovered.

The simplest example:

new Intl.NumberFormat('en-US').format(1234567.89)
// "1,234,567.89"
new Intl.NumberFormat('de-DE').format(1234567.89)
// "1.234.567,89"
new Intl.NumberFormat('hi-IN').format(1234567.89)
// "12,34,567.89" (Indian numbering system, lakhs and crores)
new Intl.NumberFormat('ar-EG').format(1234567.89)
// "١٬٢٣٤٬٥٦٧٫٨٩" (Arabic numerals, Egyptian separators)

The same value, formatted for four locales, produces four correct outputs. The thousands separator changes (comma in English, period in German, comma at different positions in Indian, formal Unicode separator in Arabic). The decimal mark changes. The digit characters change for Arabic. The grouping algorithm changes for Indian (which uses lakhs — groups of two after the first three digits).

None of this requires application code beyond the Intl.NumberFormat call. The platform knows.

The options object covers most variations:

new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
}).format(99.5)
// "$99.50"
new Intl.NumberFormat('en-US', {
style: 'percent'
}).format(0.42)
// "42%"
new Intl.NumberFormat('en-US', {
notation: 'compact'
}).format(1234567)
// "1.2M"
new Intl.NumberFormat('en-US', {
style: 'unit',
unit: 'kilometer-per-hour'
}).format(95)
// "95 km/h"

Currency formatting handles symbol placement, decimal precision (some currencies have zero decimal places — JPY, KRW), and locale-specific currency display ($1,000.00 in en-US, 1.000,00 € in de-DE). Compact notation produces 1.2M, 3.4K, 5.6B with locale-appropriate suffixes. Unit formatting handles unit names and locale-specific abbreviations.

For most number formatting needs, Intl.NumberFormat is sufficient. Libraries like Numeral.js and the locale-specific helpers in lodash are largely replaceable by direct use of the platform’s API.

Intl.DateTimeFormat does for dates and times what Intl.NumberFormat does for numbers:

new Intl.DateTimeFormat('en-US', {
dateStyle: 'full',
timeStyle: 'short'
}).format(new Date())
// "Sunday, May 18, 2026 at 3:42 PM"
new Intl.DateTimeFormat('ja-JP', {
dateStyle: 'full'
}).format(new Date())
// "2026年5月18日日曜日"
new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Tokyo',
dateStyle: 'short',
timeStyle: 'short'
}).format(new Date())
// "5/19/26, 4:42 AM" (Tokyo time)

The API handles calendars (Gregorian, Hebrew, Islamic, Japanese era, Chinese, Persian, and a dozen others), time zones (every IANA time-zone identifier), regional date formats, and combinations of date and time precision. The dateStyle and timeStyle options cover most common formats; for custom formats, individual options control year, month, day, hour, minute, second, era, weekday, and time-zone display.

The platform handles time zones natively. Storing a date in UTC and formatting it for the user’s local time zone (or any other time zone) is two lines of code. No library required.

The forthcoming Temporal API — currently at Stage 3 in TC39, reaching browsers in 2024–2025 — addresses the broader Date API’s well-known design flaws. Temporal provides immutable date/time/duration types, clean time-zone handling, calendar-aware operations, and a much more pleasant authoring API than Date. Ujjwal Sharma has been the primary TC39 champion through the proposal’s long development. Once Temporal ships in all major browsers, the case for date libraries (date-fns, dayjs, luxon) becomes much weaker than it is today — and it’s already weaker than most teams’ habits suggest.

A particularly useful Intl addition is RelativeTimeFormat:

const rtf = new Intl.RelativeTimeFormat('en-US')
rtf.format(-5, 'minute') // "5 minutes ago"
rtf.format(3, 'hour') // "in 3 hours"
rtf.format(-1, 'day') // "1 day ago"
rtf.format(2, 'week') // "in 2 weeks"

The API produces the X minutes ago / in Y hours phrasing every social application uses, in any supported locale, with correct singular/plural handling, correct future/past wording, and locale-appropriate phrasing. The library code that used to wrap this (in moment.js, date-fns/formatDistance, and a thousand custom implementations) is now built in.

ListFormat, PluralRules, and the Quieter Tools

Section titled “ListFormat, PluralRules, and the Quieter Tools”

A few more Intl tools that solve specific localization problems most code gets subtly wrong.

Intl.ListFormat formats a list of values according to locale conventions:

new Intl.ListFormat('en-US').format(['apples', 'oranges', 'bananas'])
// "apples, oranges, and bananas"
new Intl.ListFormat('en-US', { type: 'disjunction' }).format(['red', 'green', 'blue'])
// "red, green, or blue"
new Intl.ListFormat('es-ES').format(['manzanas', 'naranjas', 'plátanos'])
// "manzanas, naranjas y plátanos"

The Oxford comma decision, the and vs or distinction, the locale-specific list separator, and the trailing conjunction are all handled. The same operation, hand-coded, is the source of countless bugs (the Bob, Carol and Ted vs Bob, Carol, and Ted style decisions, the appears in 3 messages vs appears in 3 message singular/plural bugs).

Intl.PluralRules reports which plural form a number belongs to, in any supported locale:

const pr = new Intl.PluralRules('en-US')
pr.select(0) // 'other'
pr.select(1) // 'one'
pr.select(2) // 'other'
const prAr = new Intl.PluralRules('ar-EG')
prAr.select(0) // 'zero'
prAr.select(1) // 'one'
prAr.select(2) // 'two'
prAr.select(3) // 'few'
prAr.select(11) // 'many'
prAr.select(100) // 'other'

English has two plural categories (one and other). Arabic has six (zero, one, two, few, many, other). Russian has four. Polish has four. Different languages have very different plural systems, and the Unicode plural rules encode them precisely. Hand-coding if count === 1 then “1 item” else count + ” items” breaks immediately in any language that isn’t English. Intl.PluralRules handles the entire vocabulary correctly.

Intl.Collator sorts strings according to locale:

const collator = new Intl.Collator('de-DE')
['Müller', 'Mueller', 'Maier'].sort(collator.compare)
// ['Maier', 'Mueller', 'Müller'] (German collation)
const collatorTr = new Intl.Collator('tr-TR')
// Turkish has dotted and dotless 'i', sorted differently

Naive array.sort() uses Unicode code-point order, which produces nonsensical results for almost any non-ASCII content. Intl.Collator uses the locale’s actual sorting rules — case sensitivity, accent handling, character composition, and language-specific orderings like Turkish’s two-i system.

Intl.Segmenter (shipped 2022) breaks text into locale-appropriate units:

const segmenter = new Intl.Segmenter('en-US', { granularity: 'word' })
const segments = segmenter.segment('Hello, world!')
[...segments].filter(s => s.isWordLike).map(s => s.segment)
// ['Hello', 'world']
const segmenterJa = new Intl.Segmenter('ja-JP', { granularity: 'word' })
[...segmenterJa.segment('日本語のテキスト')].filter(s => s.isWordLike).map(s => s.segment)
// ['日本語', 'の', 'テキスト']

Segmenter matters for any application that needs to break text into words, sentences, or graphemes correctly. Asian languages without word separators, complex script handling (Thai, Khmer), grapheme cluster handling for emoji and combining characters — all of this used to require dedicated libraries (ICU bindings, complex segmentation logic). The platform now does it.

Intl.DisplayNames returns localized names for languages, regions, scripts, and currencies. Intl.Locale provides structured access to locale identifiers. Intl.DurationFormat (shipping in 2024–2025) handles human-readable durations. The list keeps growing through TC39’s annual release process.

Internationalization isn’t only formatting. It’s also direction.

Languages like Arabic, Hebrew, Urdu, Persian, and several others are written right to left. Mixed-direction content (an English word inside an Arabic sentence) requires bidirectional (bidi) handling — which characters belong to which direction, how they should be rendered, how line breaks should fall.

The platform handles most of bidi automatically. Setting dir="rtl" on an element switches the rendering direction. CSS logical propertiesmargin-inline-start, padding-inline-end, border-block-start — adapt to the writing direction, so a margin that should be on the start of the text (left in LTR, right in RTL) flips automatically when the direction changes.

For an application to support RTL, it needs to:

Use logical CSS properties (inline-start, inline-end, block-start, block-end) rather than physical ones (left, right, top, bottom) wherever possible. The CSS adapts to the direction.

Set the dir attribute on <html> or relevant subtrees when the locale requires it. The browser handles the bidi rendering from there.

Use <bdi> (bidirectional isolation) for content of unknown direction — usernames, foreign words, anything user-supplied where the direction can’t be assumed.

Avoid hardcoded left/right concepts in icons, animations, and design language. An arrow that means next in LTR points the other way in RTL.

The platform supports all of this. Building an RTL-aware application is a discipline more than a technical achievement. The discipline matters for the billion-plus people who use RTL languages every day.

Chinese, Japanese, and Korean text raises additional considerations.

The character sets are large (tens of thousands of common characters), so font files are bigger and font subsetting becomes important for performance. The unicode-range CSS descriptor lets the browser load only the font subset that matches the actual characters in the page.

Ideographic punctuation has different spacing rules. The CSS text-spacing-trim property (shipping in 2024–2025) handles the spacing adjustments for ideographic content.

Vertical text is a thing. CSS writing-mode: vertical-rl lets text flow top-to-bottom, right-to-left for traditional Chinese and Japanese typography. Many design systems forget about this and break vertical layouts.

Line breaking is more subtle. CJK languages don’t use spaces between words, so the browser has to use language-specific algorithms to decide where lines can break. line-break: auto/loose/normal/strict/anywhere controls the breaking behavior.

Numerals can be in non-Arabic systems. Japanese formal documents may use kanji numerals (一、二、三). Chinese has both Arabic and Chinese numerals. Arabic itself uses Arabic-Indic digits (٠١٢٣٤٥٦٧٨٩) in some contexts. Intl.NumberFormat’s numberingSystem option handles this.

The pattern: the platform has answers. The application has to use them. Most i18n bugs are application code that didn’t realize the platform was already handling the problem.

The hardest part of i18n isn’t formatting. It’s the messages — the actual translated strings in the application’s UI.

This is where the platform doesn’t have a complete answer, and where libraries still earn their place. ICU MessageFormat — a syntax for parameterized, plural-aware, gender-aware translation strings — has been the working standard for a decade:

{count, plural,
=0 {No items}
one {# item}
other {# items}
}

The string says if count is 0, show “No items”; if count is 1, show “1 item”; otherwise show “N items”. The plural categorization uses Intl.PluralRules. The output is the right form for any locale. The library wraps this syntax with parsing, runtime evaluation, and integration with translation files.

intl-messageformat, formatjs, Polyglot.js, i18next, LinguiJS, vue-i18n, and the framework-specific translation libraries each implement variations on this pattern. The Intl.MessageFormat proposal at TC39 (championed by Eemeli Aro) aims to bring a version of MessageFormat into the platform itself — if it ships, the case for dedicated translation libraries becomes weaker too.

Until that lands, message formatting is one of the places where dependency-on-a-library still makes sense. The translation pipeline (extracting strings, sending them to translators, integrating returned translations, handling fallbacks for missing translations, hot-reloading translations in development) is a problem the libraries solve.

For Kitsune, the platform-first approach is: use Intl.* for everything the platform supports, use a small MessageFormat-compatible library for translation strings, and structure the application so the translation surface is small and well-defined. The architecture doesn’t bury translatable strings inside React components or template literals; it surfaces them as data the translation pipeline can consume.

The same structural pattern that affects accessibility (Chapter 29) affects internationalization.

Components that hardcode English-language strings inside their templates can’t be translated. Components that use string concatenation (count + ' items') break for any language with non-trivial plural rules. Components that use English-language date and number formatting break for international users. Components that assume LTR layout break in RTL contexts. CSS-in-JS systems that don’t use logical properties produce layouts that need rewriting for RTL.

The framework itself usually doesn’t break i18n; the application code on top of the framework does. The libraries to fix it (react-intl, vue-i18n, etc.) exist and are widely used. The architectural lift is making sure the application’s translation surface is identifiable, extractable, and consistent.

For Kitsune, this means a few specific commitments:

Translatable strings are explicit. Components don’t hide strings inside their templates without exposing them as something the translation pipeline can find.

Number, date, and list formatting goes through Intl.*, not through ad-hoc string manipulation.

CSS uses logical properties so layouts work in both LTR and RTL by default.

The application’s design-system tokens include direction-aware spacing where appropriate.

The architecture supports a lang attribute on the document root and dir for direction, with components inheriting from the document’s language and direction unless they explicitly override.

These commitments are small individually. Collectively, they’re the difference between an application that can support any of the world’s languages and an application that only happens to work in English.

Internationalization, like accessibility, is one of the platform’s quiet promises.

The platform supports number formatting for hundreds of locales. The platform supports date formatting with time-zone awareness. The platform supports plural rules for every supported locale. The platform supports list formatting, relative-time formatting, locale-aware sorting, locale-aware segmentation. The platform supports RTL rendering, bidirectional text, vertical writing modes, ideographic spacing, language-specific line breaking.

Most of this is available, in every major browser, without an npm install.

The default frontend approach uses almost none of it. Hardcoded English. Custom date formatting. Manual count + ' items' string concatenation. Hardcoded LTR layouts. Hardcoded comma-separated lists. Most teams ship applications that work for one locale and break in obvious ways for any other.

The platform-first argument the book builds includes internationalization as a load-bearing reason. Code written against the platform inherits the platform’s locale support. The work is done. The application has to choose to use it.

The next chapter takes a third platform-commitment area seriously — privacy and security. The browser exerts a substantial set of security and privacy positions that frontend code has to live with, from CSP and CORS to ITP and Privacy Sandbox. The chapter walks through what the platform now requires, what it now prohibits, and how to build with those constraints in mind.

Pick five values from your application — a number, a date, a duration, a list of items, a relative time. For each one, format it using Intl.* for at least three locales: your default, a different European locale (Spanish, German, French), and a non-Latin-script locale (Arabic, Japanese, or Hindi).

For each format, compare:

  1. What does the library you’re currently using produce?
  2. What does Intl produce?
  3. Are they the same? If not, which one is more correct?
  4. How many lines of code did each approach take?
  5. What does the bundle size cost look like? (Your intl-messageformat, date-fns, or moment import vs. new Intl.NumberFormat(...).)

Then look at one piece of UI text in your application that includes a count — 3 items, 5 messages, 2 hours. Check how it would behave for Russian (which has four plural forms), Arabic (six), or Polish (four). Would your current code produce the right text? Or would a Russian user see 5 items with an English plural ending where the language requires a genitive plural?

The point is to feel that the platform has done a lot of work for languages and locales, and that most frontend code is silently failing the users who don’t speak the language the team primarily speaks. The fixes are usually small. The cumulative effect of getting them right is the difference between an application that’s usable globally and one that’s only really usable in one language.