The Intl.* formatter family, the locale engine built into JavaScript that renders numbers, currency, dates, relative time, and sorted lists correctly for every locale.
Hold a single number in your head: 1234.56, an account balance. The only question that matters about rendering it is for whom? There is no neutral answer. 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 €, where the gap between the thousands isn’t a normal space but a narrow non-breaking one the keyboard can’t even type. Same value, three strings, and not one of them is more correct than the others. They are just different conventions, and 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, and the comma rules differ too. To sort a column of words, you have to know whether ä files next to a (German) or all the way after z (Swedish). Every one of these is a runtime, locale-dependent decision, and the cost of getting it wrong isn’t a crash. It’s a German customer staring at $1,234.56 and quietly deciding your product wasn’t built for them.
So who makes these decisions for you? In 2026 the answer is not a library and not a helper you write yourself. It’s the Intl.* family , the locale engine that already ships inside Node and every browser, backed by the same Unicode locale data the whole industry shares, with zero dependencies to install. The last chapter promised you two things and deferred both to here: the body of formatDate(value, { timeZone }), where you shipped the signature but never the implementation, and the render for “3 days ago” that you were told belonged to internationalization. This lesson writes both, and along the way it surveys the handful of formatters every SaaS reaches for daily. By the end you’ll have a small lib/format.ts module of cached, locale-and-timezone-correct formatters that the rest of your codebase calls without ever touching Intl.* by hand.
// 2. format: turn a value into a string (cheap, reusable)
formatter.format(1234.56); // '$1,234.56'
Two steps, always. new Intl.X(locales, options) builds a formatter instance configured for one locale and one set of options. Then .format(value) turns a value into a string. That’s the whole contract, and it’s identical across NumberFormat, DateTimeFormat, RelativeTimeFormat, Collator, and the rest, so you learn it once. Some formatters add two more methods on top. .formatToParts(value) returns the output broken into labeled tokens instead of one string, which you reach for when you need to style a piece on its own, such as wrapping just the currency symbol in a <span>. And .formatRange(a, b) renders a span between two values idiomatically. But the spine is always those two steps.
The second idea is what separates code that scales from code that doesn’t: construct once, reuse. Building the formatter is the expensive step. When you call new Intl.NumberFormat(...), the runtime loads and prepares a slice of CLDR , the locale database it bundles. MDN says it plainly: an Intl.DateTimeFormat instance “may decide to cache a slice of the database,” which is exactly why you want to hold onto the instance instead of throwing it away. Calling .format() on an already-built formatter, by contrast, is cheap.
Now picture where this bites. You render a table of a thousand invoice rows, and each row’s amount cell does new Intl.NumberFormat(locale, opts).format(amount). That’s a thousand constructions, each loading its CLDR slice from scratch, when one shared formatter would have done. Construction runs in the low-double-digit milliseconds; multiply by a thousand and you’ve burned whole seconds of CPU formatting numbers. The same mistake hides inside value.toLocaleString(locale, opts): that convenience method builds a throwaway formatter on every single call. Reach for it once and it’s fine; reach for it in a loop and you’ve written the scale bug without a single new in sight.
The defense is a tiny memo cache at module scope. Build a formatter the first time a given locale-and-options combination is asked for, keep it, and hand back the same instance forever after. Here’s the canonical shape for numbers. The date and collator helpers later in this lesson are this exact pattern with a different constructor swapped in.
The Map lives at module scope, so it persists for the life of the process, and every call across every request shares it. This is where the formatters accumulate.
The cache key has to capture both inputs that change the output: the locale and the options. JSON.stringify(options) folds the whole options object into the key, so { style: 'currency', currency: 'USD' } and { style: 'percent' } never collide.
The hot path. If a formatter for this exact key already exists, return it, with no construction and no CLDR reload. After the first call for a given locale-and-options pair, this is the only branch that ever runs.
The cold path, taken once per unique key: build the formatter, store it under the key, return it. Every subsequent call with the same key now 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 do this caching for you internally. The reason lib/format.ts does it by hand is that this module is the home for formatting that runs outside the React tree (utility functions, scripts, tests) where those hooks aren’t available. Same engine, two front doors.
Intl.NumberFormat is the one you’ll reach for most, so it’s the one we go deepest on. It earns its keep by fixing a bug you have almost certainly written.
Here’s the instinct. You have a balance and you want a dollar amount, so you write `$${value.toFixed(2)}`. It looks right in development. It is wrong in production, and not subtly:
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. value.toFixed(2) plus a string-concatenated symbol is the bug this whole section exists to retire.
new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value);
// 'en-US' + 'USD' → '$1,234.56'
// 'de-DE' + 'EUR' → '1.234,56 €'
The locale and the currency code do all the work. Grouping, decimal mark, symbol, and symbol placement all follow from the two arguments, and currency is data you read from the account, never a constant you type.
The option that does the steering is style. It has five settings, and you reach for them often enough that it’s worth seeing each one with its one gotcha.
'currency' is the one you just saw. It requires a currency option, a three-letter ISO 4217 code like 'USD' or 'EUR'. Make this a habit: the currency code is data, not a UI constant. It comes from invoice.currency, never from a string literal in your component, because an invoice in euros rendered with a hard-coded '$' is a lie about money. A second option, currencyDisplay, controls how the currency is shown. 'symbol' is the default ('$', but 'US$' in some locales to disambiguate), 'narrowSymbol' forces the short form ('$' even where the default would say 'US$') and is the better choice for compact UI, while 'code' shows 'USD' and 'name' spells out 'US dollars'.
'percent' has the trap that catches everyone exactly once: the input is the fraction, not the percentage. Pass 0.15 and you get '15%'; pass 15 thinking that’s fifteen percent and you get '1,500%'. The formatter multiplies by a hundred for you, so your job is to hand it the ratio.
'decimal' is a plain number with locale-correct grouping, and it’s where you control precision. minimumFractionDigits and maximumFractionDigits pin how many decimals show. It’s the locale-aware replacement for the toFixed(2) habit, except it also groups the thousands.
'compact', set via notation: 'compact', abbreviates large numbers: 12000 becomes '12K', and 1700000 becomes '1.7M'. Pair it with compactDisplay: 'short' | 'long' to choose between '12K' and '12 thousand'. Reach for this on dashboard KPI 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 whole reason this family exists, in a single view. The same two lines of code, run against three locales, produce three correctly localized strings. Click through the tabs and watch the conventions shift:
The shared inputs are the same for all three tabs: a currency amount of 1234.56 (in USD or EUR), a percent fraction of 0.1538, and a compact number 1700000. Only the locale changes.
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.
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(1234.56);
// '1.234,56 €'
new Intl.NumberFormat('de-DE', { style: 'percent', maximumFractionDigits: 1 }).format(0.1538);
// '15,4 %'
new Intl.NumberFormat('de-DE', { notation: 'compact' }).format(1700000);
// '1,7 Mio.'
Dot groups thousands, comma for decimals, symbol trails.
new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(1234.56);
// '1 234,56 €'
new Intl.NumberFormat('fr-FR', { style: 'percent', maximumFractionDigits: 1 }).format(0.1538);
// '15,4 %'
new Intl.NumberFormat('fr-FR', { notation: 'compact' }).format(1700000);
// '1,7 M'
Narrow non-breaking space groups thousands; symbol trails.
Notice you wrote the conventions for none of those. You didn’t know German trails the euro symbol or that French groups with a narrow space, and you didn’t have to. That knowledge lives in CLDR; you supplied a locale and a value. That’s the deal the family offers, and it’s why the rule is firm: every currency, percent, and large-number render goes through Intl.NumberFormat.value.toFixed(2) and a concatenated symbol are the bug.
One sharp edge to guard before you wrap this. formatter.format(NaN) doesn’t throw; it returns a localized 'NaN' string, which then ships to your user as the literal text “NaN” where a balance should be. A null amount coerces and misbehaves similarly. Catch nullish and NaN inputs at the wrapper, before the value ever reaches the formatter, because the formatter is not your validation layer.
The fastest way to make this stick is to write it. Intl.NumberFormat is a native global, with no install and no imports, so this runs instantly. Implement the two helpers below. formatMoney takes an amount in cents (the integer-minor-unit storage you just read about), so your first job is to divide by a hundred 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%.
AI feedback
Output
The locale and the 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. That’s the habit to leave with.
Rendering Temporal values with Intl.DateTimeFormat
This is the section that pays off last chapter. You arrive holding a Temporal.Instant, a Temporal.PlainDate, or maybe a Temporal.ZonedDateTime in memory, and now you have to render one at the edge of the app. Intl.DateTimeFormat is where the Temporal substrate and the formatter family meet, and there’s one interop rule here that throws at runtime if you get it wrong. So we get it exactly right.
Modern Intl.DateTimeFormat.prototype.format() understands Temporal types directly, with no new Date() and no .toISOString() round-trip ever. But “understands Temporal” has a precise boundary, and it’s the single fact in this lesson most likely to surprise you. 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. Read that twice, because it’s counterintuitive: the one Temporal type that already knows its own timezone is the one you can’t pass to .format().
The reasoning, once you see it, 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 you two correct shapes, and you pick by what you’re holding:
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. This is the shape every render in the app uses.
// A ZonedDateTime already knows its zone — render it through its own method.
// Do NOT pass timeZone here — it comes from the object itself.
// Or convert to an Instant first, then format with an explicit zone:
formatter.format(zdt.toInstant());
When you’re holding a ZonedDateTime. Use its own .toLocaleString(), and do not pass a timeZone option, because the zone comes from the object. Or call .toInstant() and fall back to the primary path. Passing a ZonedDateTime straight to .format() throws a TypeError.
So commit the rule to memory: passing a ZonedDateTime straight to .format() throws a TypeError. Render it with its own .toLocaleString(locale, options) (no timeZone option, since the object supplies it) or convert with .toInstant() first. Everything else in the app flows through the primary path: formatter.format(instant), with the formatter carrying the zone.
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. On Vercel that’s UTC; on your laptop under pnpm dev it’s wherever you happen to live. Same code, two machines, two different answers, and no error to warn you. This is the same bug as the no-argument Intl.DateTimeFormat() you were taught to watch for, now wearing the costume of a date render. The structural fix is the same one you designed last chapter: a wrapper whose timeZone argument is required, so the broken call is impossible to write.
Time to make good on the promise. Last chapter you shipped the signature of formatDate(value, { locale, timeZone, ...options }) and were told the body waited for internationalization. Here it is, and it’s small, because the shape is the lesson, not exhaustive overloads:
import { Temporal } from'@/lib/temporal';
type FormatDateValue = Temporal.Instant| Temporal.PlainDate;
type FormatDateOptions = Intl.DateTimeFormatOptions& {
The options type makes locale and timeZonerequired: not optional, not defaulted. You cannot call formatDate without naming a zone, which is what makes the Vercel-UTC bug impossible to express. This is the shape last chapter promised.
import { Temporal } from'@/lib/temporal';
type FormatDateValue = Temporal.Instant| Temporal.PlainDate;
type FormatDateOptions = Intl.DateTimeFormatOptions& {
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& {
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& {
Format and return. For an Instant, the formatter’s timeZone resolves the wall-clock; for a PlainDate, the calendar fields render directly. One line, because the wrapper did the hard part at its boundary.
1 / 1
That’s the deliverable. Every date render in the codebase now goes through formatDate, and there’s no call site where the timezone gets to be a runtime accident.
A note on the options you pass it. The default an experienced engineer reaches for 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. When you need finer control you switch to component options (year, month, day, hour, and so on), specifying 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:
Reach for this 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. Note too that Japanese isn’t written in the Latin alphabet, yet the engine handles the script without you doing anything special:
Last chapter drew a clean line and pointed it here. Arithmetic with Temporalcomputes the gap between two moments as a Temporal.Duration; turning that duration into the words “3 days ago” is a locale-aware render that belongs to internationalization. This is where you finish it.
The formatter is Intl.RelativeTimeFormat, and the call takes two arguments: a signed number and a unit.
// '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, falling back to the numeric form otherwise. It reads like a human wrote it.
rtf.format(-1, 'day'); // '1 day ago' (never 'yesterday')
rtf.format(0, 'second'); // 'in 0 seconds'
Always numeric.'always' forces the counted form, so -1 day is '1 day ago', never 'yesterday'. Useful when you need consistency, but watch the zero case (below).
The sign carries direction: negative is past ('3 days ago'), and positive is future ('in 3 days'). The unit is one of 'second', 'minute', 'hour', 'day', 'week', 'month', 'quarter', or 'year', and it takes exactly one. That’s the catch when you’re coming from a Duration, which can be “1 day, 3 hours, 12 minutes” all at once. The render needs you to pick the single largest non-zero unit and its signed value. That picking logic is exactly what your formatRelative helper should hide, so every caller writes one line.
This is the second deliverable last chapter deferred. Recall from Arithmetic with Temporal 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 };
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 };
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 };
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 };
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
That helper tops out at days, and on purpose: a Duration measured between two Instants can only be balanced down to days, hours, minutes, and seconds. Months and years aren’t fixed-length, so an Instant refuses to balance into them. You’d need relativeTo a calendar point to know how long “a month” was. 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,” you measure against a PlainDate or ZonedDateTime instead, which know their calendar. Don’t reach for it speculatively.
Look closely at that now parameter, because it’s load-bearing. It’s a required argument, passed in: the helper never reads the current time itself. That’s deliberate, and it defends against a real production bug. If you computed “3 days ago” from Date.now()inside the helper, the server would stamp one “now” while rendering and the browser would compute a slightly later “now” when it hydrates, and React would warn about a hydration mismatch , the same trap Profile timezone warned about. The senior shape is a single stable “now” anchor, captured once per request and threaded down as a prop, so server and client agree on the same instant.
So what about timestamps that should tick, “2 minutes ago” becoming “3 minutes ago” while the user watches? Don’t reach for a render timer that re-renders the tree, because that reintroduces the mismatch. The experienced choice is a small client island that owns its own interval and re-renders only itself. We won’t build it here. Just know the rule: the stable now is an argument, and live-updating is an isolated island, never a tree-wide timer.
One last guard, which the helper above already handles. With numeric: 'always', a value that lands on zero renders 'in 0 seconds', which is nonsense for a timestamp. Fall the zero case through to 'now'; numeric: 'auto' does it for you.
Locale-aware sorting and search with Intl.Collator
item10 lands before item2 because the default .sort() compares strings character by character, and '1' sorts before '2', so 'item10' beats 'item2' on the third character. It also has no idea what to do with accents: depending on your data, ä might sort before a or after z, and the default won’t match what any human in any locale expects. Both problems have one fix, Intl.Collator, a locale-aware comparator.
The shape is clean. new Intl.Collator(locale) returns an object with a .compare method, and that method has exactly the (a, b) => number signature Array.prototype.sort wants, so you hand it over directly:
Three options carry most of the value. Start with the one that fixed the sort above. numeric: true turns on natural-numeric ordering, so 'item2' precedes 'item10' the way a person reads them. It’s the default you want for anything with numbers in it: 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 you want a user typing “cafe” to match “café”. 'accent' distinguishes accents but ignores case, the right default for sorting a list that has diacritics in it. 'case' and the default 'variant' are stricter still.
usage tells the collator its job: 'sort' (the default) optimizes for ordering, while 'search' optimizes for substring and equality matching. Set it to 'search' when you’re using the collator to filter rather than to order.
Now the anti-pattern, and it ties straight back to “construct once, reuse.” You’ll be tempted to skip the collator and reach for String.prototype.localeCompare inside the sort callback. Don’t: that line constructs a fresh collator on every single comparison.
Constructs a collator per comparison.localeCompare builds a fresh formatter every time it’s called, and .sort calls it 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.
One construction, reused across every comparison. Build the collator once, hand .compare to .sort. In production this lives behind a getCollator(locale, options) cache, exactly like the number and date formatters.
That’s the same lesson the cache helper taught at the top, now in a sort: build the comparator once, then reuse it across every comparison. In lib/format.ts it lives behind a getCollator(locale, options) memo, identical in shape to getNumberFormatter.
You’ve already met Intl.PluralRules. The ICU MessageFormat lesson showed that every ICU plural message delegates to it under the hood: it’s the thing that maps a number to a CLDR category ('one', 'other', 'few', 'many'…) per locale. So this is a quick callback, not a new topic. It also stands on its own as a member of the formatter family.
When would you 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 for the rare case of “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 it to ordinal categories, the engine behind selectordinal and “1st / 2nd / 3rd”.
But hold the boundary the last lesson drew, because it’s the part that keeps you out of trouble: if the variant is text, it belongs in the catalog as an ICU plural string, not a PluralRules branch in your component. A count === 1 ? 'message' : 'messages' written by hand, even routed through PluralRules, is the bug that lesson exists to prevent: it bakes English’s two-form assumption into code that Russian (four forms) and Arabic (six) will read. Direct use of PluralRules is the exception, reserved for non-string outputs. The default is always the catalog.
Two formatters round out the daily-reach set. You’ll meet them properly when you need them; for now, 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 whenever you’re joining a 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'
Two reaches. A locale picker renders each option in its own language ('français', not 'French'), which is exactly what the locale switcher you build next lesson needs. And region or currency labels render in the user’s locale.
That second DisplayNames trick, each language labeled in its own tongue, is precisely what a good language switcher does, and you’ll wire one up in the next lesson on locale resolution.
One rule underlies every formatter in this lesson, and it’s the one most likely to ship a subtle bug: the locale you pass is a contract, and 'en' is an incomplete one.'en' doesn’t pin down a date order or a currency convention. 'en-US' writes 6/13/2026 and leads prices with $, while 'en-GB' writes 13/06/2026 and reaches for £. Hand a formatter a bare 'en' and you get some default the runtime picked, not the convention your user actually expects.
So the locale must be a full BCP 47 tag, language-REGION, all the way down. In this app the source of truth is the users.locale profile column (built next lesson, paired with the users.timeZone you already have). It stores the complete tag, and the formatter receives it directly. A bare 'en' sitting in that column is a code smell: it means something upstream dropped the region, and every render for that user is now running on a runtime guess.
This gives you a reviewer’s checklist, the specific patterns that mean a locale-aware render is silently wrong. When you read your own diffs or someone else’s, scan 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(newDate()); // 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 there is a render that 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 real skill this lesson teaches. Test yourself: which of these renders silently 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 outputRenders incorrectly for some locale or environment
Correct but a scale bugRight 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
A quick check on the two you might second-guess. The 'en' tag and the no-arg toLocaleString() both run fine and look fine in the only environment you tested them in. That’s exactly why they’re “wrong,” not “slow”: the failure surfaces only for a de-DE user or on Vercel’s UTC box. The two scale bugs, by contrast, are correct for everyone; they just rebuild a CLDR-loading formatter thousands of times where one cached instance would do. Wrong-for-someone versus wasteful-for-everyone is the line.
If you sorted those cleanly, you’ve got the lesson. The family isn’t a grab-bag of methods to memorize. It’s one shape (new Intl.X(locale, options) → .format(value)), one performance rule (construct once, reuse), and one discipline (locale and timezone are required data, never runtime guesses). Everything in lib/format.ts is those three ideas wearing different constructors.
You’ve built the engine. The formatters in lib/format.ts are cached, locale-correct, and timezone-safe, and they run anywhere, inside or outside React. What’s still missing is how the locale gets chosen in the first place (the resolution chain you’ll wire next), and the in-tree hooks that wrap these same constructors for use inside components. Both are coming. The mechanics you learned here don’t change; that wiring is just a thinner front door onto the exact engine you now understand.