Skip to content
Chapter 84Lesson 3

The Intl.* formatter family

The Intl.* family, JavaScript's built-in locale engine for numbers, currency, dates, relative time, and sorted lists.

Hold one number in your head: 1234.56, an account balance. The only question that matters about rendering it is for whom? To an American it’s $1,234.56. To a German it’s 1.234,56 €: the comma and dot swap roles, and the symbol moves to the end. To a French reader it’s 1 234,56 €, with a narrow non-breaking space between the thousands. Same value, three strings, none more correct than the others. Which one is right is decided entirely by who’s looking.

The same problem waits behind every human-facing value in your product. “3 days ago” is il y a 3 jours in French and vor 3 Tagen in German. A list of names joined with “and” in English is joined with “et” in French, with different comma rules. Sorting a column of words means knowing whether ä files next to a (German) or after z (Swedish). Each is a runtime, locale-dependent decision, and getting it wrong isn’t a crash: it’s a German customer staring at $1,234.56 and deciding your product wasn’t built for them.

So who makes these decisions? In 2026 the answer is the Intl.* family : a locale engine that already ships in Node and every browser, backed by shared Unicode locale data, with zero dependencies to install. The last chapter deferred two things to here, the body of formatDate(value, { timeZone }) whose signature you shipped without an implementation, and the render for “3 days ago”. This lesson writes both and surveys the formatters every web app reaches for daily. By the end you’ll have a small lib/format.ts of cached, locale-and-timezone-correct formatters the rest of your codebase calls without touching Intl.* by hand.

Every formatter in the family shares two ideas. Learn them here and the rest of the lesson is variations on a theme.

The first is the shape. Each formatter is built and used the same way:

// 1. construct: configure for one locale + one set of options
const formatter = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' });
// 2. format: turn a value into a string (cheap, reusable)
formatter.format(1234.56); // '$1,234.56'

new Intl.X(locales, options) builds a formatter instance for one locale and one set of options; .format(value) then turns a value into a string. This contract is identical across NumberFormat, DateTimeFormat, RelativeTimeFormat, Collator, and the rest, so you learn it once. Some formatters add .formatToParts(value), which returns the output as labeled tokens so you can style one piece on its own (wrapping just the currency symbol in a <span>), and .formatRange(a, b), which renders the span between two values. The spine is always those two steps.

The second idea separates code that scales from code that doesn’t: construct once, reuse. Construction is the expensive step. When you call new Intl.NumberFormat(...), the runtime loads a slice of CLDR , the locale database it bundles, and may cache it on the instance. Calling .format() on a built formatter is cheap by comparison, so you want to hold onto the instance rather than throw it away.

Picture where this bites. You render a table of a thousand invoice rows, each cell doing new Intl.NumberFormat(locale, opts).format(amount). That’s a thousand constructions, each loading its CLDR slice from scratch, where one shared formatter would do. Construction runs in the low tens of milliseconds; times a thousand, you burn whole seconds of CPU formatting numbers. The same trap hides in value.toLocaleString(locale, opts): that convenience method builds a throwaway formatter on every call. Fine once, a scale bug in a loop, with no new in sight.

The defense is a small memo cache at module scope: build a formatter the first time a given locale-and-options pair is asked for, then hand back the same instance forever. Here is the shape for numbers. The date and collator helpers later in this lesson are the same pattern with a different constructor.

const numberFormatters = new Map<string, Intl.NumberFormat>();
export const getNumberFormatter = (
locale: string,
options: Intl.NumberFormatOptions = {},
): Intl.NumberFormat => {
const key = `${locale}:${JSON.stringify(options)}`;
const cached = numberFormatters.get(key);
if (cached) return cached;
const formatter = new Intl.NumberFormat(locale, options);
numberFormatters.set(key, formatter);
return formatter;
};

The Map lives at module scope, so it persists for the life of the process and every call across every request shares it. The formatters accumulate here.

const numberFormatters = new Map<string, Intl.NumberFormat>();
export const getNumberFormatter = (
locale: string,
options: Intl.NumberFormatOptions = {},
): Intl.NumberFormat => {
const key = `${locale}:${JSON.stringify(options)}`;
const cached = numberFormatters.get(key);
if (cached) return cached;
const formatter = new Intl.NumberFormat(locale, options);
numberFormatters.set(key, formatter);
return formatter;
};

The key must capture both inputs that change the output: locale and options. JSON.stringify(options) folds the options object into the key, so { style: 'currency', currency: 'USD' } and { style: 'percent' } never collide.

const numberFormatters = new Map<string, Intl.NumberFormat>();
export const getNumberFormatter = (
locale: string,
options: Intl.NumberFormatOptions = {},
): Intl.NumberFormat => {
const key = `${locale}:${JSON.stringify(options)}`;
const cached = numberFormatters.get(key);
if (cached) return cached;
const formatter = new Intl.NumberFormat(locale, options);
numberFormatters.set(key, formatter);
return formatter;
};

The hot path: if a formatter for this key exists, return it, with no construction and no CLDR reload. After the first call for a given pair, this is the only branch that runs.

const numberFormatters = new Map<string, Intl.NumberFormat>();
export const getNumberFormatter = (
locale: string,
options: Intl.NumberFormatOptions = {},
): Intl.NumberFormat => {
const key = `${locale}:${JSON.stringify(options)}`;
const cached = numberFormatters.get(key);
if (cached) return cached;
const formatter = new Intl.NumberFormat(locale, options);
numberFormatters.set(key, formatter);
return formatter;
};

The cold path, taken once per unique key: build the formatter, store it, return it. Every later call with the same key hits the cheap branch above.

1 / 1

One forward note so you don’t build this twice: when you wire up next-intl in a couple of lessons, its formatter hooks cache internally. lib/format.ts does it by hand because this module serves formatting that runs outside the React tree (utility functions, scripts, tests), where those hooks aren’t available.

Intl.NumberFormat is the formatter you’ll reach for most, and the one worth the most depth, because it fixes a bug you have almost certainly shipped.

The instinct: you have a balance, you want a dollar amount, so you write `$${value.toFixed(2)}`. It looks right in development and is wrong in production.

`$${value.toFixed(2)}`; // '$1234.56'

Wrong three ways at once: no thousands separator (1234.56, not 1,234.56), a '$' hard-coded for accounts that might be in euros or yen, and a decimal point that’s a comma in half the world.

The style option does the steering. Five settings cover almost everything you’ll render, each with one gotcha.

'currency' is the one above. It requires a currency option, a three-letter ISO 4217 code like 'USD'. The gotcha is conceptual: the currency code is data, not a UI constant. It comes from invoice.currency, never a literal, because an invoice in euros rendered with a hard-coded '$' is a lie about money. (A secondary currencyDisplay option switches '$' for 'USD' or 'US dollars'; 'narrowSymbol' forces the short symbol in tight UI.)

'percent' takes the fraction, not the percentage. Pass 0.15 to get '15%'; pass 15 and you get '1,500%'. The formatter multiplies by a hundred, so hand it the ratio.

'decimal' is a plain number with locale-correct grouping, and where you set precision. minimumFractionDigits and maximumFractionDigits pin the decimals shown. It’s the locale-aware replacement for toFixed(2), and it groups the thousands too.

'compact', via notation: 'compact', abbreviates large numbers: 12000 becomes '12K', 1700000 becomes '1.7M'. compactDisplay: 'short' | 'long' chooses between '12K' and '12 thousand'. Use it on dashboard tiles where space is tight and exact digits don’t matter.

'unit' formats measurements: { style: 'unit', unit: 'kilometer' } gives '1,234 km', and 'megabyte' or 'hour' work the same way.

Now the payoff. The same two lines, run against three locales, produce three correctly localized strings: a currency amount of 1234.56, a percent fraction of 0.1538, and a compact 1700000, with only the locale changing.

new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(1234.56);
// '$1,234.56'
new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1 }).format(0.1538);
// '15.4%'
new Intl.NumberFormat('en-US', { notation: 'compact' }).format(1700000);
// '1.7M'
Comma groups thousands, dot for decimals, symbol leads.

You wrote none of those conventions. You may not know that German trails the euro symbol or that French groups with a narrow space; that knowledge lives in CLDR, and you supplied only a locale and a value. That’s why the rule is firm: every currency, percent, and large-number render goes through Intl.NumberFormat.

One sharp edge before you wrap this: formatter.format(NaN) doesn’t throw, it returns a localized 'NaN' string that ships to your user as the literal text “NaN” where a balance should be. A null amount coerces and misbehaves the same way. Catch nullish and NaN inputs at the wrapper, before they reach the formatter; the formatter is not your validation layer.

Implement the two helpers below. formatMoney takes an amount in cents, the integer-minor-unit storage you just read about, so divide by 100 before formatting; formatPercent takes a fraction.

Implement formatMoney(amountInCents, currency, locale) to return a correctly grouped currency string, and formatPercent(fraction, locale) to return a percentage. Remember: formatMoney receives the amount in cents, so divide by 100 first; formatPercent receives a fraction, so 0.15 is 15%.

    The locale and currency arguments carry the entire load: you never touched a comma, a dot, or a symbol, and the percent took a fraction, not a number.

    Rendering Temporal values with Intl.DateTimeFormat

    Section titled “Rendering Temporal values with Intl.DateTimeFormat”

    You arrive at the edge of the app holding a Temporal.Instant, a Temporal.PlainDate, or maybe a Temporal.ZonedDateTime, and now you have to render it. Intl.DateTimeFormat is where the Temporal substrate and the formatter family meet, and one interop rule here throws at runtime if you get it wrong.

    Intl.DateTimeFormat.prototype.format() understands Temporal types directly, with no new Date() and no .toISOString() round-trip. But “understands Temporal” has a precise boundary. It accepts Temporal.Instant and all the plain calendar and clock types: PlainDate, PlainDateTime, PlainTime, PlainYearMonth, PlainMonthDay. It rejects Temporal.ZonedDateTime with a TypeError, on purpose: the one Temporal type that already knows its own timezone is the one you can’t pass to .format().

    The reason is clean. A ZonedDateTime carries its own zone, and the formatter also has a timeZone option, so passing one to the other sets up two conflicting sources of truth, and the spec refuses to guess. That leaves two correct shapes, and you pick by what you’re holding:

    const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone: 'America/New_York',
    dateStyle: 'long',
    });
    formatter.format(instant); // 'June 13, 2026'

    The primary path, and what formatDate does. The formatter carries the timeZone; the Instant is a zoneless moment in UTC, and the formatter resolves it into the right wall-clock for that zone. Every render in the app uses this shape.

    This brings back the discipline from Profile timezone. A DateTimeFormat built without a timeZone option doesn’t error; it silently defaults to the runtime’s zone, which is UTC on Vercel and your local zone under pnpm dev. Same code, two machines, two answers, no warning. The structural fix is the one you designed last chapter: a wrapper whose timeZone argument is required, so the broken call is impossible to write.

    Last chapter you shipped the signature of formatDate(value, { locale, timeZone, ...options }) and deferred the body to internationalization. Here it is:

    import { Temporal } from '@/lib/temporal';
    type FormatDateValue = Temporal.Instant | Temporal.PlainDate;
    type FormatDateOptions = Intl.DateTimeFormatOptions & {
    locale: string;
    timeZone: string;
    };
    export const formatDate = (
    value: FormatDateValue,
    { locale, timeZone, ...options }: FormatDateOptions,
    ): string => {
    const formatter = getDateFormatter(locale, { timeZone, ...options });
    return formatter.format(value);
    };

    The options type makes locale and timeZone required: not optional, not defaulted. You cannot call formatDate without naming a zone, which is what makes the Vercel-UTC bug impossible to express.

    import { Temporal } from '@/lib/temporal';
    type FormatDateValue = Temporal.Instant | Temporal.PlainDate;
    type FormatDateOptions = Intl.DateTimeFormatOptions & {
    locale: string;
    timeZone: string;
    };
    export const formatDate = (
    value: FormatDateValue,
    { locale, timeZone, ...options }: FormatDateOptions,
    ): string => {
    const formatter = getDateFormatter(locale, { timeZone, ...options });
    return formatter.format(value);
    };

    Destructure locale and timeZone out, and collect everything else (dateStyle, timeStyle, component options) into ...options to pass through. The signature stays two-positional: the value, then one options object.

    import { Temporal } from '@/lib/temporal';
    type FormatDateValue = Temporal.Instant | Temporal.PlainDate;
    type FormatDateOptions = Intl.DateTimeFormatOptions & {
    locale: string;
    timeZone: string;
    };
    export const formatDate = (
    value: FormatDateValue,
    { locale, timeZone, ...options }: FormatDateOptions,
    ): string => {
    const formatter = getDateFormatter(locale, { timeZone, ...options });
    return formatter.format(value);
    };

    Pull a cached Intl.DateTimeFormat from getDateFormatter, the same module-scope memo cache as getNumberFormatter with the date constructor swapped in. Construction happens once per locale-and-options key.

    import { Temporal } from '@/lib/temporal';
    type FormatDateValue = Temporal.Instant | Temporal.PlainDate;
    type FormatDateOptions = Intl.DateTimeFormatOptions & {
    locale: string;
    timeZone: string;
    };
    export const formatDate = (
    value: FormatDateValue,
    { locale, timeZone, ...options }: FormatDateOptions,
    ): string => {
    const formatter = getDateFormatter(locale, { timeZone, ...options });
    return formatter.format(value);
    };

    Format and return. For an Instant, the formatter’s timeZone resolves the wall-clock; for a PlainDate, the calendar fields render directly.

    1 / 1

    Every date render in the codebase now goes through formatDate, with no call site where the timezone can be a runtime accident.

    A note on the options you pass it. The default is the style presets: dateStyle ('short' | 'medium' | 'long' | 'full'), usually paired with timeStyle. They’re the least code and the most locale-idiomatic, because you say “long date” and let CLDR decide what long means in Japanese. For finer control, switch to component options (year, month, day, hour, and so on) and specify each field. The trap: mixing a preset with a component option throws. Pick one mode per formatter, dateStyle: 'long' or { year: 'numeric', month: 'short' }, never both.

    For spans of dates, formatRange(a, b) renders idiomatically and collapses the shared parts:

    const formatter = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
    formatter.formatRange(start, end); // 'Jan 5 – 7, 2026'
    // 'fr-FR' → '5–7 janv. 2026'

    Reach for it on booking windows, billing periods, and event durations: 'Jan 5 – 7, 2026' reads the way a person writes it, not as two full dates glued together.

    Here’s the same Instant rendered with dateStyle: 'long' across three locales. Watch the month name translate and the field order rearrange, and note that Japanese renders in its own script without any special handling:

    formatDate(instant, { locale: 'en-US', timeZone: 'America/New_York', dateStyle: 'long' });
    // 'June 13, 2026'
    Month name, then day, then year.

    Rendering “3 days ago” with Intl.RelativeTimeFormat

    Section titled “Rendering “3 days ago” with Intl.RelativeTimeFormat”

    Temporal arithmetic computes the gap between two moments as a Temporal.Duration. Turning that duration into the words “3 days ago” is a locale-aware render, so it lands here.

    The formatter is Intl.RelativeTimeFormat, and format takes a signed number and a unit.

    const rtf = new Intl.RelativeTimeFormat('en-US', { numeric: 'auto' });
    rtf.format(-1, 'day'); // 'yesterday'
    rtf.format(-3, 'day'); // '3 days ago'
    // 'fr-FR' → 'il y a 3 jours' 'de-DE' → 'vor 3 Tagen'

    The default you want. 'auto' lets the locale substitute special-cased words like 'yesterday', 'tomorrow', and 'now' where it has them, and falls back to the numeric form otherwise.

    The sign carries direction: negative is past ('3 days ago'), positive is future ('in 3 days'). The unit is one of 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', or 'year', and you pass exactly one. That’s the catch coming from a Duration, which can be “1 day, 3 hours, 12 minutes” at once: you have to pick the single largest non-zero unit and its signed value. Hide that picking inside a formatRelative helper so every caller writes one line.

    Recall that instant.since(now) returns a Temporal.Duration, negative when instant is before now, which is precisely the “ago” case. Here’s the helper:

    import { Temporal } from '@/lib/temporal';
    const UNITS = [
    ['days', 'day'],
    ['hours', 'hour'],
    ['minutes', 'minute'],
    ['seconds', 'second'],
    ] as const;
    type FormatRelativeOptions = { locale: string; now: Temporal.Instant };
    export const formatRelative = (
    instant: Temporal.Instant,
    { locale, now }: FormatRelativeOptions,
    ): string => {
    const duration = instant.since(now, { largestUnit: 'day' });
    const formatter = getRelativeTimeFormatter(locale, { numeric: 'auto' });
    for (const [field, unit] of UNITS) {
    const value = duration[field];
    if (value !== 0) return formatter.format(value, unit);
    }
    return formatter.format(0, 'second');
    };

    The units, ordered largest to smallest. Each row pairs the Duration field name ('days') with the singular name RelativeTimeFormat expects ('day'). The as const keeps both as exact literals, so no casting is needed below. We’ll scan this list in order and stop at the first non-zero unit.

    import { Temporal } from '@/lib/temporal';
    const UNITS = [
    ['days', 'day'],
    ['hours', 'hour'],
    ['minutes', 'minute'],
    ['seconds', 'second'],
    ] as const;
    type FormatRelativeOptions = { locale: string; now: Temporal.Instant };
    export const formatRelative = (
    instant: Temporal.Instant,
    { locale, now }: FormatRelativeOptions,
    ): string => {
    const duration = instant.since(now, { largestUnit: 'day' });
    const formatter = getRelativeTimeFormatter(locale, { numeric: 'auto' });
    for (const [field, unit] of UNITS) {
    const value = duration[field];
    if (value !== 0) return formatter.format(value, unit);
    }
    return formatter.format(0, 'second');
    };

    Measure the gap as a Duration. since(now) is negative when instant is in the past, exactly the sign RelativeTimeFormat reads as “ago”. largestUnit: 'day' is the coarsest unit an Instant can balance to; asking it for months or years throws.

    import { Temporal } from '@/lib/temporal';
    const UNITS = [
    ['days', 'day'],
    ['hours', 'hour'],
    ['minutes', 'minute'],
    ['seconds', 'second'],
    ] as const;
    type FormatRelativeOptions = { locale: string; now: Temporal.Instant };
    export const formatRelative = (
    instant: Temporal.Instant,
    { locale, now }: FormatRelativeOptions,
    ): string => {
    const duration = instant.since(now, { largestUnit: 'day' });
    const formatter = getRelativeTimeFormatter(locale, { numeric: 'auto' });
    for (const [field, unit] of UNITS) {
    const value = duration[field];
    if (value !== 0) return formatter.format(value, unit);
    }
    return formatter.format(0, 'second');
    };

    Walk units largest-first; the first non-zero field is the one to show. A balanced Duration has whole-number fields, so value is an integer; the paired unit is already the singular the formatter wants, and the sign carries direction.

    import { Temporal } from '@/lib/temporal';
    const UNITS = [
    ['days', 'day'],
    ['hours', 'hour'],
    ['minutes', 'minute'],
    ['seconds', 'second'],
    ] as const;
    type FormatRelativeOptions = { locale: string; now: Temporal.Instant };
    export const formatRelative = (
    instant: Temporal.Instant,
    { locale, now }: FormatRelativeOptions,
    ): string => {
    const duration = instant.since(now, { largestUnit: 'day' });
    const formatter = getRelativeTimeFormatter(locale, { numeric: 'auto' });
    for (const [field, unit] of UNITS) {
    const value = duration[field];
    if (value !== 0) return formatter.format(value, unit);
    }
    return formatter.format(0, 'second');
    };

    Every unit was zero, so the two instants are the same moment. Fall back to the neighborhood of 'now'; numeric: 'auto' renders (0, 'second') as 'now'.

    1 / 1

    The helper tops out at days on purpose: a Duration between two Instants balances only down to days, hours, minutes, and seconds. Months and years aren’t fixed-length, so an Instant refuses to balance into them. For most “X ago” surfaces, activity feeds, “last seen,” “edited 4 hours ago”, days-and-down is exactly the range you want. When a product genuinely needs “2 months ago,” measure against a PlainDate or ZonedDateTime instead, which know their calendar.

    That now parameter is load-bearing. It’s a required argument: the helper never reads the current time itself. Computing “3 days ago” from Date.now() inside the helper would stamp one “now” on the server during render and a slightly later “now” in the browser at hydration, and React would warn about a hydration mismatch , the same trap Profile timezone warned about. The senior shape is one stable “now” anchor, captured once per request and threaded down as a prop, so server and client agree on the same instant.

    What about timestamps that should tick, “2 minutes ago” becoming “3 minutes ago” while the user watches? A render timer that re-renders the tree reintroduces the mismatch. The fix is a small client island that owns its own interval and re-renders only itself. We won’t build it here. The rule: the stable now is an argument, and live-updating is an isolated island, never a tree-wide timer.

    One last guard, already handled above. With numeric: 'always', a value of zero renders 'in 0 seconds', nonsense for a timestamp. Fall the zero case through to 'now', which numeric: 'auto' gives you.

    Locale-aware sorting and search with Intl.Collator

    Section titled “Locale-aware sorting and search with Intl.Collator”

    Sort a list of strings the obvious way and you hit two bugs at once:

    ['item2', 'item10', 'item1'].sort(); // ['item1', 'item10', 'item2'] — 10 before 2

    item10 lands before item2 because the default .sort() compares character by character, and '1' sorts before '2'. Accents are the second bug: depending on your data, ä might sort before a or after z, never where a reader expects. Intl.Collator, a locale-aware comparator, fixes both.

    new Intl.Collator(locale) returns an object whose .compare method has exactly the (a, b) => number signature Array.prototype.sort wants, so you hand it over directly:

    const collator = new Intl.Collator('en-US', { numeric: true });
    items.sort(collator.compare); // ['item1', 'item2', 'item10']

    Three options carry most of the value.

    numeric: true turns on natural-numeric ordering, so 'item2' precedes 'item10' the way a person reads them. Reach for it whenever the strings contain numbers: filenames, version strings, invoice numbers.

    sensitivity decides what counts as “the same” letter. 'base' ignores both accents and case (a = á = A), the right default for search and equality, where a user typing “cafe” should match “café”. 'accent' distinguishes accents but ignores case, the right default for sorting a list with diacritics. 'case' and the default 'variant' are stricter still.

    usage tells the collator its job: 'sort' (the default) optimizes for ordering, 'search' for substring and equality matching. Set it to 'search' when you filter rather than order.

    Now the anti-pattern. Instead of a collator, you can call String.prototype.localeCompare inside the sort callback, but that constructs a fresh collator on every comparison.

    items.sort((a, b) => a.localeCompare(b, 'en-US', { numeric: true }));

    Constructs a collator per comparison. .sort calls the callback O(n log n) times, so a 10,000-row sort builds tens of thousands of collators — the same scale bug as a formatter inside a render loop.

    The plural engine, restated: Intl.PluralRules

    Section titled “The plural engine, restated: Intl.PluralRules”

    You’ve already met Intl.PluralRules: the ICU MessageFormat lesson showed that every ICU plural message delegates to it, mapping a number to a CLDR category ('one', 'other', 'few', 'many'…) per locale.

    Call it directly only when the variant you’re choosing between isn’t text. You can’t put a React icon or a CSS class in a translation catalog, so to pick one of two icons by plural category, you reach for the engine yourself:

    new Intl.PluralRules('en-US').select(1); // 'one'
    new Intl.PluralRules('en-US', { type: 'ordinal' }).select(1); // 'one' → '1st'

    select(n) returns the cardinal category by default; { type: 'ordinal' } switches to ordinal categories, the engine behind selectordinal and “1st / 2nd / 3rd”.

    If the variant is text, it belongs in the catalog as an ICU plural string, not a PluralRules branch in your component. A hand-written count === 1 ? 'message' : 'messages', even routed through PluralRules, bakes English’s two-form assumption into code that Russian (four forms) and Arabic (six) will read. The catalog is the default; direct use is the exception, reserved for non-text outputs.

    Two more formatters round out the daily-reach set; learn the one call and the one reason to reach for each.

    Intl.ListFormat

    Reach for it when joining a translatable list of items.

    new Intl.ListFormat('en-US', { type: 'conjunction' }).format(['Alice', 'Bob', 'Carol']);
    // 'Alice, Bob, and Carol' · 'fr-FR' → 'Alice, Bob et Carol'

    Types are 'conjunction' (and), 'disjunction' (or), and 'unit'. The separator commas and the final conjunction are locale-specific, so for any list a user will read, never array.join(', ').

    Intl.DisplayNames

    Reach for it when naming a language, region, or currency.

    new Intl.DisplayNames('en-US', { type: 'language' }).of('fr'); // 'French'
    new Intl.DisplayNames('fr-FR', { type: 'language' }).of('fr'); // 'français'

    A locale picker renders each option in its own language ('français', not 'French'); region and currency labels render in the user’s locale.

    Pass a complete locale tag, not a bare language

    Section titled “Pass a complete locale tag, not a bare language”

    The locale you pass is a contract, and 'en' is an incomplete one: it pins down neither date order nor currency convention. 'en-US' writes 6/13/2026 and leads prices with $; 'en-GB' writes 13/06/2026 and reaches for £. Hand a formatter a bare 'en' and you get whatever default the runtime picked, not the convention your user expects.

    So the locale must be a full BCP 47 tag, language-REGION. In this app the source of truth is the users.locale profile column (built next lesson), which stores the complete tag and hands it to the formatter directly. A bare 'en' in that column is a code smell: something upstream dropped the region, and every render for that user is now a runtime guess.

    That gives you a reviewer’s checklist, the patterns that mean a locale-aware render is silently wrong. Scan your diffs for these:

    value.toLocaleString(); // no args → runtime locale (and tz, for dates: the Vercel-UTC bug)
    new Intl.NumberFormat(locale, opts); // inside a render or sort callback → the scale bug
    `$${value.toFixed(2)}`; // hard-coded symbol, no grouping
    formatter.format(new Date()); // a Date in a Temporal codebase — convert at the seam
    items.sort((a, b) => a.localeCompare(b)); // a fresh collator per comparison
    names.join(', '); // a translatable list — use Intl.ListFormat
    relativeTime(Date.now() - then); // relative time from Date.now() math → drifts, mismatches

    Every line compiles, passes a quick local glance, and is wrong for someone: a German customer, a thousand-row table, a screen reader hitting “NaN”. Spotting them on sight is the skill. Which of these renders wrong, and which is just slow?

    Each snippet has a problem. Sort it by *how* it fails: does it produce the wrong output for some user, or the right output at a performance cost? Drag each item into the bucket it belongs to, then press Check.

    Silently wrong output Renders incorrectly for some locale or environment
    Correct but a scale bug Right output, wasteful construction
    new Date().toLocaleString() in a Server Component
    `$${amount.toFixed(2)}` for a EUR invoice
    '3 days ago' from Date.now() in the render body
    passing a stored locale of 'en' to Intl.DateTimeFormat
    new Intl.NumberFormat(locale, opts) inside a 1,000-row .map
    arr.sort((a, b) => a.localeCompare(b)) on 10,000 rows

    The line is wrong-for-someone versus wasteful-for-everyone. The 'en' tag and the no-arg toLocaleString() run fine and look fine in the one environment you tested, then fail only for a de-DE user or on Vercel’s UTC box. The scale bugs are correct for everyone; they just rebuild a CLDR-loading formatter thousands of times where one cached instance would do.