Every codebase I have ever poked through has a formatDate util, a formatCurrency util,
and a pluralize helper someone wrote at 6pm on a Thursday. All three are bugs waiting for
their first non-English user. The browser has shipped correct, locale-aware versions of all
of them for years, and they are collectively about as underused as any API on the platform.
Here is the playground I built to make the differences visible. Flip the locale and watch what changes:
I18n formatting playground
'use client'
import { useMemo, useState } from 'react'
type Direction = 'ltr' | 'rtl'
interface LocaleOption {
code: string
label: string
nativeLabel: string
currency: string
direction: Direction
}
const LOCALE_OPTIONS = [
{
code: 'en-US',
label: 'English (US)',
nativeLabel: 'English',
currency: 'USD',
direction: 'ltr',
},
{
code: 'fr-FR',
label: 'French (France)',
nativeLabel: 'Français',
currency: 'EUR',
direction: 'ltr',
},
{
code: 'ja-JP',
label: 'Japanese (Japan)',
nativeLabel: '日本語',
currency: 'JPY',
direction: 'ltr',
},
{
code: 'ar-EG',
label: 'Arabic (Egypt)',
nativeLabel: 'العربية',
currency: 'EGP',
direction: 'rtl',
},
] as const satisfies readonly LocaleOption[]
type LocaleKey = (typeof LOCALE_OPTIONS)[number]['code']
type PluralCategory = ReturnType<Intl.PluralRules['select']>
type PluralLabels = { other: string } & Partial<Record<PluralCategory, string>>
interface LocaleCopy {
eyebrow: string
title: string
invoice: string
due: string
activeUsers: string
reviewers: PluralLabels
collaborators: readonly string[]
}
const COPY = {
'en-US': {
eyebrow: 'Locale preview',
title: 'Quarterly rollout',
invoice: 'Invoice total',
due: 'Due',
activeUsers: 'Active users',
reviewers: { one: 'reviewer', other: 'reviewers' },
collaborators: ['Design', 'Engineering', 'Research'],
},
'fr-FR': {
eyebrow: 'Aperçu régional',
title: 'Déploiement trimestriel',
invoice: 'Total de la facture',
due: 'Échéance',
activeUsers: 'Utilisateurs actifs',
reviewers: { one: 'relecteur', other: 'relecteurs' },
collaborators: ['Design', 'Ingénierie', 'Recherche'],
},
'ja-JP': {
eyebrow: 'ロケールプレビュー',
title: '四半期ロールアウト',
invoice: '請求合計',
due: '期限',
activeUsers: 'アクティブユーザー',
reviewers: { other: 'レビュー担当者' },
collaborators: ['デザイン', 'エンジニアリング', 'リサーチ'],
},
'ar-EG': {
eyebrow: 'معاينة اللغة',
title: 'إطلاق ربع سنوي',
invoice: 'إجمالي الفاتورة',
due: 'تاريخ الاستحقاق',
activeUsers: 'المستخدمون النشطون',
reviewers: {
zero: 'مراجع',
one: 'مراجع',
two: 'مراجعان',
few: 'مراجعين',
many: 'مراجعًا',
other: 'مراجع',
},
collaborators: ['التصميم', 'الهندسة', 'البحث'],
},
} as const satisfies Record<LocaleKey, LocaleCopy>
const REVIEWER_MIN = 0
const REVIEWER_MAX = 12
const SAMPLE_AMOUNT = 128430.75
const SAMPLE_USERS = 1289400
const SAMPLE_DATE = new Date('2026-05-28T16:30:00Z')
const RELATIVE_DAYS = 3
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0 rounded-(--radius-card) border border-(--color-border) bg-(--color-bg) p-4">
<p className="truncate text-xs text-(--color-fg-muted)">{label}</p>
<p className="mt-2 truncate text-lg font-semibold text-(--color-fg) tabular-nums">{value}</p>
</div>
)
}
function DetailRow({ term, children }: { term: string; children: React.ReactNode }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3">
<dt className="text-(--color-fg-muted)">{term}</dt>
<dd className="max-w-sm text-(--color-fg)">{children}</dd>
</div>
)
}
function formatPreview(locale: LocaleOption, reviewerCount: number) {
const numberLocale = locale.code
const currency = new Intl.NumberFormat(numberLocale, {
style: 'currency',
currency: locale.currency,
maximumFractionDigits: locale.currency === 'JPY' ? 0 : 2,
})
const date = new Intl.DateTimeFormat(numberLocale, {
dateStyle: 'full',
timeStyle: 'short',
// Pin to UTC so SSR (build TZ) and client (runtime TZ) format the same
// string — otherwise the time differs and React reports a hydration mismatch.
timeZone: 'UTC',
})
const compact = new Intl.NumberFormat(numberLocale, {
notation: 'compact',
maximumFractionDigits: 1,
})
const relative = new Intl.RelativeTimeFormat(numberLocale, {
numeric: 'auto',
style: 'long',
})
const pluralRules = new Intl.PluralRules(numberLocale)
const list = new Intl.ListFormat(numberLocale, {
style: 'long',
type: 'conjunction',
})
const category = pluralRules.select(reviewerCount)
const copy: LocaleCopy = COPY[locale.code as LocaleKey]
const reviewerLabel = copy.reviewers[category] ?? copy.reviewers.other
return {
amount: currency.format(SAMPLE_AMOUNT),
date: date.format(SAMPLE_DATE),
compactUsers: compact.format(SAMPLE_USERS),
relativeDue: relative.format(RELATIVE_DAYS, 'day'),
reviewerLabel,
collaboratorList: list.format(copy.collaborators),
copy,
pluralCategory: category,
}
}
export default function I18nFormattingPlayground() {
const [localeCode, setLocaleCode] = useState<LocaleKey>('en-US')
const [reviewerCount, setReviewerCount] = useState(2)
const locale = LOCALE_OPTIONS.find((option) => option.code === localeCode) ?? LOCALE_OPTIONS[0]
const preview = useMemo(() => formatPreview(locale, reviewerCount), [locale, reviewerCount])
return (
<div className="mx-auto flex w-md max-w-full flex-col gap-4 text-sm">
{/* Controls: locale select + reviewer stepper, one compact row */}
<div className="flex items-end gap-3 rounded-(--radius-card) border border-(--color-border) bg-(--color-surface) p-3">
<div className="min-w-0 flex-1">
<label
htmlFor="locale-select"
className="mb-1.5 block font-mono text-[10px] tracking-[0.18em] text-(--color-fg-subtle) uppercase"
>
Locale
</label>
<select
id="locale-select"
value={localeCode}
onChange={(event) => setLocaleCode(event.target.value as LocaleKey)}
className="w-full rounded-md border border-(--color-border) bg-(--color-bg) px-3 py-2 text-sm text-(--color-fg) outline-none focus:border-(--color-accent)"
>
{LOCALE_OPTIONS.map((option) => (
<option key={option.code} value={option.code}>
{option.nativeLabel} · {option.code}
</option>
))}
</select>
</div>
<div>
<p className="mb-1.5 font-mono text-[10px] tracking-[0.18em] text-(--color-fg-subtle) uppercase">
Reviewers
</p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setReviewerCount((count) => Math.max(REVIEWER_MIN, count - 1))}
disabled={reviewerCount === REVIEWER_MIN}
className="size-8 rounded-md border border-(--color-border) text-(--color-fg-muted) transition-colors enabled:hover:text-(--color-fg) disabled:opacity-40"
aria-label="Decrease reviewer count"
>
−
</button>
<span className="w-6 text-center font-mono text-(--color-fg) tabular-nums">
{reviewerCount}
</span>
<button
type="button"
onClick={() => setReviewerCount((count) => Math.min(REVIEWER_MAX, count + 1))}
disabled={reviewerCount === REVIEWER_MAX}
className="size-8 rounded-md border border-(--color-border) text-(--color-fg-muted) transition-colors enabled:hover:text-(--color-fg) disabled:opacity-40"
aria-label="Increase reviewer count"
>
+
</button>
</div>
</div>
</div>
{/* Live formatted preview: re-renders in the selected locale + direction */}
<div
dir={locale.direction}
aria-live="polite"
className="rounded-(--radius-card) border border-(--color-border) bg-(--color-surface) p-5"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="font-mono text-[10px] tracking-[0.18em] text-(--color-fg-subtle) uppercase">
{preview.copy.eyebrow}
</p>
<h3 className="mt-2 text-xl font-medium tracking-[-0.02em] text-(--color-fg)">
{preview.copy.title}
</h3>
</div>
<span className="shrink-0 rounded-full border border-(--color-border) px-3 py-1 font-mono text-[10px] text-(--color-fg-subtle)">
{locale.label}
</span>
</div>
<div className="mt-5 grid grid-cols-2 gap-3">
<Stat label={preview.copy.invoice} value={preview.amount} />
<Stat label={preview.copy.activeUsers} value={preview.compactUsers} />
</div>
<dl className="mt-3 grid gap-2.5 rounded-(--radius-card) border border-(--color-border) bg-(--color-bg) p-4">
<DetailRow term={preview.copy.due}>{preview.date}</DetailRow>
<DetailRow term="Relative">{preview.relativeDue}</DetailRow>
<DetailRow term="Plural">
{reviewerCount} {preview.reviewerLabel}
</DetailRow>
<DetailRow term="List">{preview.collaboratorList}</DetailRow>
</dl>
<p className="mt-4 font-mono text-[10px] text-(--color-fg-subtle)">
{locale.direction.toUpperCase()} · plural{' '}
<span className="text-(--color-accent)">{preview.pluralCategory}</span>
</p>
</div>
</div>
)
}
The roster
Intl.NumberFormat does currency, percentages, compact notation ("1.2M"), and units
("12 km/h"). The killer detail is that it knows things you would get wrong: the yen has no
minor unit, Germany swaps the comma and period, and some locales put the currency symbol
after the amount. style: 'currency', currency: 'EUR' is the whole API surface you need.
Intl.DateTimeFormat replaces the usual date library for display purposes. The
dateStyle and timeStyle presets ("full", "long", "medium", "short") map to what a locale
actually expects instead of a format string you invented. If you are shipping MM/DD/YYYY
to the whole world, this is the two-line fix.
Intl.RelativeTimeFormat produces "yesterday", "in 3 days", "vor 2 Tagen". Everyone
builds this by hand with a chain of if-statements, and everyone's version disagrees about
when "yesterday" becomes "2 days ago". The platform's version has an opinion per locale.
Intl.PluralRules is the one that ends the count === 1 ? 'item' : 'items' pattern.
English has two plural categories. Arabic has six. Polish has rules about numbers ending
in 2 through 4. You cannot ternary your way out of this, and you do not have to.
Intl.ListFormat turns ['Ana', 'Ben', 'Chika'] into "Ana, Ben, and Chika", including
the locale's stance on the Oxford comma. It is the smallest API in the family and it still
beats arr.join(', ') the moment a second language shows up.
Intl.Segmenter is the sleeper. It splits text by grapheme, word, or sentence, which is
the only correct way to truncate a string that might contain emoji or scripts without
spaces. str.slice(0, 20) can cut a family emoji in half. The segmenter never will.
Intl.Collator sorts strings the way the locale expects, so "ä" lands where a German
or a Swede expects it (they disagree, which is the point).
The habit
Treat locale as data that flows into the component, and hand every human-facing value to an
Intl formatter on the way out. Construct formatters once and reuse them (new Intl.NumberFormat is not free), or memoize by locale. That is the entire discipline.
The formatting library you are maintaining is not more correct than the platform. It is just more yours. Delete it.