Area: Formatting for people in different languages and places
Related: Browser APIs (map)
What is it?
Intl is the built-in JavaScript API for formatting and comparing things the way people expect in their language and region: dates, times, numbers, money, “3 days ago”, lists, plurals, alphabetical sorting, and splitting text into characters or words.
It ships in every browser and in Node, with the world’s locale data (from the Unicode CLDR project) already inside. You don’t need moment.js or big locale files.
new Intl.DateTimeFormat("en-AU", { dateStyle: "medium" }).format(new Date(2026, 9, 20));
// "20 Oct 2026"
new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(new Date(2026, 9, 20));
// "Oct 20, 2026"
new Intl.DateTimeFormat("ko-KR", { dateStyle: "medium" }).format(new Date(2026, 9, 20));
// "2026. 10. 20."Never build dates, numbers or plurals by gluing strings together. Ask
Intl. It knows the rules for every language, and you don’t.
Locales
A locale is a tag like en-AU (English, Australia), ko-KR, pt-BR, zh-Hant-TW. You pass one (or a list, in order of preference) to every Intl constructor.
new Intl.NumberFormat(["fr-CA", "fr", "en"]); // first one supported wins
new Intl.NumberFormat(undefined); // the user's default (browser/OS setting)
navigator.language; // e.g. "en-AU"
navigator.languages; // the user's full preference listIntl.Locale lets you inspect and build tags:
const loc = new Intl.Locale("en-AU");
loc.language; // "en"
loc.region; // "AU"Intl.DateTimeFormat
const fmt = new Intl.DateTimeFormat("en-AU", {
dateStyle: "full", // "full" | "long" | "medium" | "short"
timeStyle: "short",
timeZone: "Australia/Sydney",
});
fmt.format(new Date()); // "Tuesday 20 October 2026 at 9:15 am"Or pick parts yourself:
new Intl.DateTimeFormat("en-AU", {
weekday: "short", day: "numeric", month: "short", hour: "numeric", minute: "2-digit",
}).format(date); // "Tue 20 Oct, 9:15 am"You can’t mix dateStyle/timeStyle with individual fields like weekday in one formatter. Pick one style.
Time zones are the real difficulty
A Date is a single moment in time (milliseconds since 1970 UTC). It has no time zone. The time zone only matters when you display it.
- Without
timeZone, the formatter uses the device’s zone. - With
timeZone: "Asia/Seoul", it shows the time in Seoul, wherever the user is. Intl.DateTimeFormat().resolvedOptions().timeZonetells you the device’s zone, like"Australia/Sydney".
A meeting at 10:00 in Sydney is 08:00 in Seoul. Store moments as UTC timestamps (or ISO strings with Z), and only convert when showing them.
formatToParts: when you need to style pieces
new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short" }).formatToParts(date);
// [{type:"day", value:"20"}, {type:"literal", value:" "}, {type:"month", value:"Oct"}]Use this when the day should be bold and the month grey. Never split the formatted string yourself, because the order changes between languages.
formatRange
fmt.formatRange(start, end); // "20–22 Oct 2026", with shared parts mergedIntl.RelativeTimeFormat
const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
rtf.format(-1, "day"); // "yesterday"
rtf.format(-3, "hour"); // "3 hours ago"
rtf.format(2, "week"); // "in 2 weeks"numeric: "auto" gives “yesterday” and “tomorrow”. numeric: "always" gives “1 day ago”.
It doesn’t decide the unit for you. You work out “is this best said in minutes, hours or days?”:
function timeAgo(date: Date, locale?: string) {
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const seconds = Math.round((date.getTime() - Date.now()) / 1000);
const units: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31536000], ["month", 2592000], ["week", 604800],
["day", 86400], ["hour", 3600], ["minute", 60],
];
for (const [unit, size] of units) {
if (Math.abs(seconds) >= size) return rtf.format(Math.round(seconds / size), unit);
}
return rtf.format(seconds, "second");
}Intl.NumberFormat
new Intl.NumberFormat("en-AU").format(1234567.891); // "1,234,567.891"
new Intl.NumberFormat("de-DE").format(1234567.891); // "1.234.567,891"
new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(19.5);
// "$19.50"
new Intl.NumberFormat("en-US", { style: "currency", currency: "AUD" }).format(19.5);
// "A$19.50", because to a US reader "$" means US dollars
new Intl.NumberFormat("en", { notation: "compact" }).format(12500); // "12.5K"
new Intl.NumberFormat("en", { style: "percent" }).format(0.42); // "42%"
new Intl.NumberFormat("en", { style: "unit", unit: "megabyte" }).format(3.2); // "3.2 MB"Money: never use floats for amounts in calculations (0.1 + 0.2 !== 0.3). Keep integer cents and divide only to display.
Intl.Collator: sorting that people agree with
The default array.sort() compares UTF-16 code units, which gives wrong-looking results:
["banana", "Apple", "cherry", "éclair"].sort();
// ["Apple", "banana", "cherry", "éclair"] ...looks ok, but:
["item10", "item2", "item1"].sort();
// ["item1", "item10", "item2"] ✗A Collator sorts the way a person would:
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
["item10", "item2", "item1"].sort(collator.compare);
// ["item1", "item2", "item10"] ✓numeric: true: “natural sort”, so2 < 10. Essential for file names.sensitivity: "base": ignores case and accents when comparing, so"a" === "A" === "á". Handy for “does this name already exist?” checks.- The locale changes the rules. In Swedish,
äsorts afterz. In German, it sorts witha.
Create the collator once and reuse collator.compare. Calling a.localeCompare(b, locale, options) inside a sort creates a new collator on every comparison, which is very slow on big lists.
Intl.Segmenter: what is a “character” or a “word”?
JavaScript strings are UTF-16, and .length counts code units, not characters people see:
"👍".length; // 2
"👨👩👧".length; // 8, one family emoji made of several joined code points
"é".length; // 1 or 2, depending on how it was typedSo text.slice(0, 10) can cut an emoji in half and show �. Segmenter splits by grapheme (what a person sees as one character):
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
[...seg.segment("👨👩👧 hi")].length; // 4: family, space, h, iAnd by word, which matters for languages that don’t put spaces between words (Japanese, Chinese, Thai):
const words = new Intl.Segmenter("ja", { granularity: "word" });
for (const s of words.segment("今日は良い天気です")) {
if (s.isWordLike) console.log(s.segment);
}Use it for: truncating text safely, counting characters in a limit (“280 characters”), word counts, moving the cursor one character at a time, and search tokenising.
Intl.PluralRules and Intl.ListFormat
Plurals differ a lot by language. English has “one” and “other”. Arabic has six forms. Korean has one.
const pr = new Intl.PluralRules("en");
pr.select(1); // "one"
pr.select(5); // "other"
const msg = { one: "1 file", other: `${n} files` }[pr.select(n)];In practice you rarely call it directly. Message libraries using ICU MessageFormat ({count, plural, one {# file} other {# files}}) call it for you.
new Intl.ListFormat("en", { type: "conjunction" }).format(["Ana", "Ben", "Cy"]);
// "Ana, Ben, and Cy"
new Intl.ListFormat("en-AU", { type: "conjunction" }).format(["Ana", "Ben", "Cy"]);
// "Ana, Ben and Cy"Performance
Constructing an Intl formatter is slow (it loads locale data and resolves options). Calling format is fast. So:
- Create formatters once (module level, or
useMemo) and reuse them. - Don’t use
date.toLocaleDateString(locale, options)inside a loop over thousands of rows. It builds a new formatter each time.
Server rendering trap: hydration mismatches
With server-side rendering (Next.js and others), the server formats the date first, then the browser formats it again while hydrating. If they differ, React warns about a hydration mismatch, and the text may flicker.
They differ because:
- The server’s time zone is usually UTC. The user’s isn’t.
- The server’s locale may differ from the browser’s.
- “3 minutes ago” changes between server render and hydration.
Fixes:
- Pass an explicit
timeZoneand locale that both sides agree on (from the user’s settings). - Or format time-sensitive values only on the client (after mount).
- For relative times, render a stable absolute value first, then switch to the relative one on the client.
A real-world example
In a typical collaborative notes app:
Intl.DateTimeFormatis by far the most used: note timestamps, version history, meeting times, admin tables.Intl.Segmentershows up wherever text has to be split by real characters or words.Intl.RelativeTimeFormatgives “x minutes ago” labels, andIntl.Collatorsorts names the way people expect.- The UI is translated with next-intl, which uses ICU MessageFormat for plurals and placeholders.
Gotchas
- A
Datehas no time zone. Only formatting does. Store UTC. - Server and browser can disagree on time zone and locale, which causes hydration mismatches.
- Build formatters once.
toLocaleStringinside a loop is slow. .lengthand.slicework on UTF-16 code units, which breaks emoji. UseSegmenterfor user-visible characters.- Default
sort()isn’t alphabetical for people. Use aCollator, withnumeric: truefor names with numbers. - Currency symbols depend on the reader’s locale, not just the currency.
- Never concatenate plural strings. Use PluralRules or ICU messages.
- Use
formatToPartsto style pieces. Don’t split formatted strings.
Practice
- Format one date in
en-AU,en-US,de-DE,ko-KRandar-EGwithdateStyle: "full". Then show the same moment in three time zones. - Write
timeAgo()and test it around the boundaries (59 s, 60 s, 23 h, 24 h). - Sort
["file10.md", "File2.md", "file1.md", "éclair.md"]with defaultsort()and with a numeric, base-sensitivity Collator. - Write a
truncate(text, n)that never cuts an emoji in half. - Benchmark
toLocaleDateStringwith options inside a 10,000-item loop vs one reusedDateTimeFormat.
Sources worth reading
- MDN: Intl (each constructor has good examples)
- MDN: Intl.Segmenter, Intl.Collator
- Unicode CLDR project (where the locale data comes from)
- FormatJS docs (ICU MessageFormat syntax)