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.

input
1234.5
EUR
de-DE1.234,50 €
en-IE€1,234.50
fr-FR1 234,50 €
A single value plus a locale produces very different text. The value never changes; only the presentation does.

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
  • locales is 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.
  • options is a plain object that tunes the output.
  • The instance is reusable. Build it once, call format many 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.

new Intl.NumberFormatcostly · onceresolvedlocale data.format(12) → cheap.format(340) → cheapAnti-pattern:rows.map(r => new Intl.NumberFormat(…).format(r)) // rebuilt every rowFix: const nf = new Intl.NumberFormat(…); rows.map(r => nf.format(r))
Construction resolves locale data and is the costly step. Formatting reuses that resolved state and is cheap. Build once, format many.

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

en-USWednesday, July 8, 2026
de-DEMittwoch, 8. Juli 2026
ar-EGالأربعاء، ٨ يوليو ٢٠٢٦
ja-JP2026年7月8日水曜日
One instant, formatted with dateStyle: 'full' across four locales. Word order, script, and separators all change; the instant does not.

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 &#123; type, value &#125; 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")
value = 1234567.891
style: decimal
1,234,567.891
currency: EUR
€1,234,567.89
unit: byte
1,234,567.891 byte
notation: compact
1.2M
The same number, 1234567.891, through four NumberFormat configurations. Style and notation reshape it while the numeric value is untouched.

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.

interactiveOne value, three knobs: locale, style, number

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.

interactiveSlide through time; the helper picks the unit

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"
count12522en-USoneotherpl-PLonefewmanySame numbers, different buckets. “2” is “other” in English but “few” in Polish; “22” is “few” too.
The same counts routed into plural categories by two languages. English collapses everything but 1 into 'other'; Polish spreads counts across one, few, and many by their last digits.

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" treats a, á, and A as equal. "accent" distinguishes a from á but not from A. "case" distinguishes a from A but 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.
sensitivitya vs ba vs áa vs A
base==
accent=
case=
variant
Collator sensitivity levels, from loosest to strictest. Each level decides which of base letter, accent, and case count as a real difference.

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.

interactiveDefault sort vs Intl.Collator, side by side

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
.length → counts code units👨 (2)ZWJ👩 (2)ZWJ👧 (2)= 8 ✗Intl.Segmenter → counts grapheme clusters👨‍👩‍👧 one cluster= 1 ✓[…str] by code point → 3 (splits the family). Only grapheme segmentation gives 1.
Naive length counts UTF-16 code units and over-counts wildly. Intl.Segmenter groups code points into grapheme clusters — the single character a reader actually perceives.

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.

interactiveType a string; count it three ways

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.

Counting graphemes in '🇮🇳a'
1/4
Variables
input="🇮🇳a"
str.length=5
The flag 🇮🇳 is two regional-indicator code points, each a surrogate pair — so .length reports 5, not 2.

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
requested (in order)“ban”“fr-CA”“de”✗ no data(not reached)resolved: “fr-CA”first supported matchIf nothing matched, the runtime’s default locale is used instead.
Locale negotiation walks your preference list against the runtime's data, taking the first match and narrowing region if needed, before landing on the default as a last resort.

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

  • Intl is 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/timeStyle shortcuts or granular components (not both), plus formatRange for spans and formatToParts for custom markup. Always set timeZone when the display zone matters.
  • NumberFormat: one API for decimals, currency (auto-rounds per currency), percent (multiplies by 100), unit, and notation: "compact" for 1.2M.
  • RelativeTimeFormat gives you “yesterday” / “in 3 days” with numeric: "auto"; you choose the unit, it handles the grammar.
  • ListFormat replaces join(", ") with real “a, b, and c” grammar; PluralRules replaces n === 1 checks with the locale’s real category set.
  • Collator sorts and searches text the human way; tune sensitivity and usage, or use numeric: true for natural number ordering.
  • Segmenter counts 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.
  • DisplayNames turns codes into translated names; resolvedOptions().locale and supportedLocalesOf let you inspect and pre-check locale negotiation.