kyle.berry
Writing

Everything Intl gives you for free

The platform ships a full localization toolkit and most of us still hand-roll formatters. A tour of what you can delete.

3 min read
  • i18n
  • engineering

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

Reviewers

2

Locale preview

Quarterly rollout

English (US)

Invoice total

$128,430.75

Active users

1.3M

Due
Thursday, May 28, 2026 at 4:30 PM
Relative
in 3 days
Plural
2 reviewers
List
Design, Engineering, and Research

LTR · plural other

component.tsx
'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.