Internationalization with Intl
You have a number, 1234.5, and a currency, EUR. Someone in Berlin should see 1.234,50 €. Someone in New York, if you formatted it as US dollars, would see $1,234.50. The grouping separator flips, the decimal mark flips, the currency symbol moves to the other side, and there’s a non-breaking space before the euro sign that you would never think to add by hand.
Multiply that by dates, plurals, sorting, and the phrase “3 days ago” — each with its own rules in each of a few hundred locales — and you can see why people once shipped megabytes of locale data to do this. You don’t have to. The browser already carries that data, and it exposes it through one namespace: Intl.
Everything here is defined by ECMA-402, the internationalization companion to the main JavaScript spec. The core formatters have been Baseline for years and are safe to use everywhere. One newer member, Intl.Segmenter, we’ll flag when we get to it.
The shape every formatter shares
Learn one Intl constructor and you mostly know them all. They follow the same shape:
const formatter = new Intl.SomeFormat(locales, options);
formatter.format(value); // → a string
formatter.formatToParts(value); // → structured tokens
localesis a BCP 47 language tag like"en-US","de","ja-JP", or an array of them in preference order. Omit it and the runtime uses its default locale.optionsis a plain object that tunes the output.- The instance is reusable. Build it once, call
formatmany times.
That last point matters more than it looks. Constructing a formatter is the expensive part — it resolves the locale and loads the relevant data. Formatting a value afterwards is cheap. So hoist your formatters; don’t create one inside a loop or a render function.
Dates and times
Intl.DateTimeFormat turns a Date into locale-correct text. The fastest way in is the paired shortcuts dateStyle and timeStyle, each accepting "full", "long", "medium", or "short":
const when = new Date("2026-07-08T14:30:00Z");
new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(when);
// "Jul 8, 2026"
new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", timeZone: "Europe/London" }).format(when);
// "Wednesday, 8 July 2026 at 15:30"
new Intl.DateTimeFormat("ja-JP", { dateStyle: "long" }).format(when);
// "2026年7月8日"
Note timeZone. A Date is a single instant in UTC; the formatter is what projects it into a named zone. Pass an IANA zone like "Asia/Kolkata" or "America/New_York" and the displayed clock time shifts accordingly, while the underlying instant stays put.
When you need finer control, drop the *Style shortcuts and name components directly:
new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
}).format(when);
// "Wed, July 8 at 10:30 AM EDT" (in America/New_York)
Same day fanned across locales
Ranges
Booking systems and event listings often show a span, not a single date. formatRange(start, end) handles the elision for you — it drops whatever the two endpoints share:
const fmt = new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric" });
fmt.formatRange(new Date("2026-07-08"), new Date("2026-07-11"));
// "July 8 – 11" (month printed once)
fmt.formatRange(new Date("2026-07-30"), new Date("2026-08-02"));
// "July 30 – August 2" (month repeated because it changed)
Both arguments must be the same type. Beyond Date, formatRange accepts the newer Temporal.PlainDate and friends; passing a Temporal.ZonedDateTime throws. If the two endpoints collapse to the same value at the chosen precision, you get a single date back instead of a range.
formatToParts: build your own string
Sometimes you want the pieces, not the finished sentence — to wrap the weekday in a <strong>, or feed the day number into a calendar cell. formatToParts returns an ordered array of { type, value } tokens:
new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).formatToParts(when);
// [
// { type: "month", value: "Jul" },
// { type: "literal", value: " " },
// { type: "day", value: "8" },
// { type: "literal", value: ", " },
// { type: "year", value: "2026" }
// ]
The literal tokens are the locale’s own glue — spaces, commas, the word “at”. Because they come from the formatter, your reassembled string stays correct in every locale.
Numbers, currency, and units
Intl.NumberFormat is the workhorse. The style option chooses the mode: "decimal" (default), "currency", "percent", or "unit".
const n = 1234567.891;
new Intl.NumberFormat("en-US").format(n);
// "1,234,567.891"
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(n);
// "$1,234,567.89"
new Intl.NumberFormat("en-US", { style: "unit", unit: "kilometer-per-hour" }).format(88);
// "88 km/h"
new Intl.NumberFormat("en-US", { style: "percent" }).format(0.1875);
// "19%" (percent multiplies by 100)
Two things trip people up. Currency mode rounds to the currency’s own number of decimal places automatically — two for USD, zero for JPY, three for BHD — so you rarely set fraction digits yourself. And percent mode multiplies your value by 100, so pass 0.19, not 19.
Compact notation
Dashboards want 1.2M, not 1,234,567. That’s notation: "compact":
const c = new Intl.NumberFormat("en-US", { notation: "compact" });
c.format(1234); // "1.2K"
c.format(1234567); // "1.2M"
c.format(1200000000);// "1.2B"
new Intl.NumberFormat("de-DE", { notation: "compact" }).format(1234567);
// "1,2 Mio." (localized abbreviation, not "M")
Change any of the three controls below and watch the same value re-render. The generated line under the result is exactly the call that produced it — currency mode auto-picks a sensible currency for the locale.
formatToParts works here too, and it’s how you style just the currency symbol, or right-align the integer part in a table:
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
.formatToParts(1234.5);
// [
// { type: "currency", value: "$" },
// { type: "integer", value: "1" },
// { type: "group", value: "," },
// { type: "integer", value: "234" },
// { type: "decimal", value: "." },
// { type: "fraction", value: "50" }
// ]
“3 days ago” — relative time
Hand-writing “in 2 hours” / “5 minutes ago” logic is a classic source of off-by-one and pluralization bugs. Intl.RelativeTimeFormat owns it. You give it a signed number and a unit; the sign decides past or future:
const rtf = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" });
rtf.format(-1, "day"); // "yesterday"
rtf.format(0, "day"); // "today"
rtf.format(3, "day"); // "in 3 days"
rtf.format(-2, "hour"); // "2 hours ago"
The numeric: "auto" option is what turns -1 day into “yesterday” instead of “1 day ago”. The default, "always", keeps the number. Units run from "second" up through "year" (including "quarter" and "week").
The formatter does not decide which unit to use — that’s your job. A small helper picks the largest unit that fits:
const DIVISIONS = [
{ amount: 60, unit: "second" },
{ amount: 60, unit: "minute" },
{ amount: 24, unit: "hour" },
{ amount: 7, unit: "day" },
{ amount: 4.34524, unit: "week" },
{ amount: 12, unit: "month" },
{ amount: Infinity, unit: "year" },
];
function relativeTime(date, rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" })) {
let duration = (date - Date.now()) / 1000; // seconds, signed
for (const { amount, unit } of DIVISIONS) {
if (Math.abs(duration) < amount) return rtf.format(Math.round(duration), unit);
duration /= amount;
}
}
Drag the slider through a range of past and future offsets and watch that helper pick the largest fitting unit — and the grammar shift when you change locale.
Lists — the Oxford comma problem
["a", "b", "c"].join(", ") gives "a, b, c", which is wrong in English (no “and”) and wronger in every other language. Intl.ListFormat speaks the local grammar:
const items = ["apples", "pears", "bananas"];
new Intl.ListFormat("en-US", { type: "conjunction" }).format(items);
// "apples, pears, and bananas"
new Intl.ListFormat("en-US", { type: "disjunction" }).format(items);
// "apples, pears, or bananas"
new Intl.ListFormat("de-DE", { type: "conjunction" }).format(items);
// "apples, pears und bananas" (no comma before "und")
type picks the connector: "conjunction" (and), "disjunction" (or), or "unit" (a plain measurement list with no connector). A style of "long", "short", or "narrow" controls verbosity. The input can be any iterable of strings.
Plurals are not “add an s”
English has two forms: “1 file”, “2 files”. That is not universal. Russian has four categories. Arabic has six. Some languages have exactly one — Japanese doesn’t inflect the noun at all. If your code does count === 1 ? "file" : "files", it is quietly wrong everywhere but English.
Intl.PluralRules tells you which grammatical category a number falls into for a given locale. It returns one of "zero", "one", "two", "few", "many", "other" — but only the ones that language actually uses.
const en = new Intl.PluralRules("en-US");
en.select(0); // "other"
en.select(1); // "one"
en.select(2); // "other"
const pl = new Intl.PluralRules("pl-PL"); // Polish
pl.select(1); // "one"
pl.select(2); // "few"
pl.select(5); // "many"
pl.select(22); // "few"
You wire this to a lookup table of message strings — one per category the locale supports:
const pr = new Intl.PluralRules("en-US");
const messages = {
one: (n) => `${n} file`,
other: (n) => `${n} files`,
};
function label(n) {
return messages[pr.select(n)](n);
}
label(1); // "1 file"
label(9); // "9 files"
Set type: "ordinal" and it classifies ranking words — 1st, 2nd, 3rd — which English builds with four categories:
const ord = new Intl.PluralRules("en-US", { type: "ordinal" });
const suffix = { one: "st", two: "nd", few: "rd", other: "th" };
const nth = (n) => `${n}${suffix[ord.select(n)]}`;
nth(1); // "1st"
nth(2); // "2nd"
nth(3); // "3rd"
nth(11); // "11th"
nth(21); // "21st"
There’s also selectRange(start, end) for phrases like “1–2 days”, because the category of a range isn’t always the category of either endpoint.
Sorting and searching text correctly
Array.prototype.sort() with no comparator sorts by UTF-16 code unit. That puts "Z" before "a" and scatters accented letters to the end — nonsense to a human reader. Intl.Collator sorts the way a person from that locale expects.
const names = ["Zoë", "apple", "Ähre", "apricot", "Zulu"];
names.sort(); // ["Zoë","Zulu","Ähre","apple","apricot"] ✗
names.sort(new Intl.Collator("de").compare); // ["Ähre","apple","apricot","Zoë","Zulu"] ✓
collator.compare is a drop-in comparator: it returns a negative number, zero, or positive, exactly like sort wants. Two options carry most of the weight:
sensitivity— how picky the comparison is."base"treatsa,á, andAas equal."accent"distinguishesafromábut not fromA."case"distinguishesafromAbut not fromá."variant"(the sort default) distinguishes all three.numeric: true— makes"file10"sort after"file2"instead of before it, by comparing embedded digit runs as numbers.
For a case- and accent-insensitive “does this row match the search box” check, set usage: "search" and sensitivity: "base", then test for a zero result:
const search = new Intl.Collator("en", { usage: "search", sensitivity: "base" });
const matches = (a, b) => search.compare(a, b) === 0;
matches("cafe", "café"); // true
matches("CAFÉ", "cafe"); // true
Edit the list, flip the locale, or toggle natural-number ordering and compare the two columns: the default sort on the left versus the collator on the right.
Counting characters the way humans see them
Here’s a bug that bites nearly everyone. What is the length of the string "👨👩👧" (a family emoji)?
"👨👩👧".length; // 8
Eight. That single glyph is built from three people joined by zero-width joiners, and each person is itself a surrogate pair. .length counts UTF-16 code units, not what the user sees. [...str] (spreading by code point) does better but still splits the family into pieces, because the joiners glue several code points into one grapheme cluster — one user-perceived character.
Intl.Segmenter is the correct tool. With granularity: "grapheme" it walks the string in real characters:
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
const graphemes = [...seg.segment("👨👩👧")];
graphemes.length; // 1 ✓
// A real "visible length" helper:
function visibleLength(str) {
return [...new Intl.Segmenter().segment(str)].length;
}
visibleLength("héllo👨👩👧"); // 6
Type or paste anything — an emoji, a flag, an accented word — and watch the three counts diverge. Only the grapheme count matches what your eyes see.
Change the granularity and the same object splits by word or sentence. Word mode tags each segment with isWordLike, so you can count real words while skipping spaces and punctuation:
const words = new Intl.Segmenter("en", { granularity: "word" });
let count = 0;
for (const { segment, isWordLike } of words.segment("Hello, world! 123")) {
if (isWordLike) count++;
}
count; // 3 ("Hello", "world", "123")
This matters most for languages like Japanese and Thai that don’t put spaces between words — .split(" ") finds nothing there, but the segmenter uses the locale’s dictionary to find real boundaries.
Human-readable names for codes
You have the code "fr", "JP", or "BRL" and you want to show “French”, “Japan”, “Brazilian Real” — translated into the user’s own language. Intl.DisplayNames is the lookup table:
const lang = new Intl.DisplayNames(["en"], { type: "language" });
lang.of("fr"); // "French"
lang.of("zh-Hant"); // "Traditional Chinese"
const region = new Intl.DisplayNames(["fr"], { type: "region" });
region.of("JP"); // "Japon" (Japan, in French)
region.of("US"); // "États-Unis"
const money = new Intl.DisplayNames(["en"], { type: "currency" });
money.of("BRL"); // "Brazilian Real"
type is required and picks the category: "language", "region", "script", "currency", "calendar", or "dateTimeField". For an unknown code, of() returns undefined by default, or the code itself if you set fallback: "code".
Which locale did you actually get?
You ask for "de-DE"; the runtime might only have "de" data. You ask for a locale it’s never heard of; it falls back to something reasonable. Every formatter exposes resolvedOptions() so you can see what negotiation produced:
new Intl.NumberFormat("de-DE").resolvedOptions().locale;
// "de-DE" (or "de" if regional data wasn't available)
new Intl.DateTimeFormat("xx-badlocale").resolvedOptions().locale;
// e.g. "en-US" — the runtime's default, because "xx" matched nothing
To check availability before committing, each constructor has a static supportedLocalesOf:
Intl.DateTimeFormat.supportedLocalesOf(["ban", "id-ID", "de"]);
// ["id-ID", "de"] — "ban" (Balinese) dropped if unsupported
Putting it together
A realistic notification line touches four of these at once — a count, its plural, a list, and a relative time — and every piece stays locale-correct with zero dependencies:
function activityLine(locale, actor, friends, postedAt) {
const nf = new Intl.NumberFormat(locale);
const lf = new Intl.ListFormat(locale, { type: "conjunction" });
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
const pr = new Intl.PluralRules(locale);
const others = friends.length;
const noun = pr.select(others) === "one" ? "friend" : "friends";
const minsAgo = Math.round((postedAt - Date.now()) / 60000);
return `${lf.format([actor, `${nf.format(others)} ${noun}`])} `
+ `liked your post ${rtf.format(minsAgo, "minute")}`;
}
activityLine("en-US", "Ada", ["Grace", "Linus"], Date.now() - 5 * 60000);
// "Ada and 2 friends liked your post 5 minutes ago"
Swap "en-US" for "es-ES" or "ar-EG" and the connectors, digits, and time phrase all change with it — no template forks, no string tables for grammar.
Summary
Intlis built into every modern browser and Node. It ships the locale data with the platform, so locale-correct formatting costs you zero dependencies. It’s defined by ECMA-402.- Every formatter follows the same shape:
new Intl.X(locales, options), then a reusable.format(). Construct once, format many — construction is the expensive step. DateTimeFormat:dateStyle/timeStyleshortcuts or granular components (not both), plusformatRangefor spans andformatToPartsfor custom markup. Always settimeZonewhen the display zone matters.NumberFormat: one API for decimals,currency(auto-rounds per currency),percent(multiplies by 100),unit, andnotation: "compact"for1.2M.RelativeTimeFormatgives you “yesterday” / “in 3 days” withnumeric: "auto"; you choose the unit, it handles the grammar.ListFormatreplacesjoin(", ")with real “a, b, and c” grammar;PluralRulesreplacesn === 1checks with the locale’s real category set.Collatorsorts and searches text the human way; tunesensitivityandusage, or usenumeric: truefor natural number ordering.Segmentercounts and iterates user-perceived characters (grapheme clusters), the only correct way to measure emoji and combined scripts. It’s the newest piece — Baseline newly available since 2024, widely available around late 2026.DisplayNamesturns codes into translated names;resolvedOptions().localeandsupportedLocalesOflet you inspect and pre-check locale negotiation.