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 list

Intl.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().timeZone tells 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 merged

Intl.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”, so 2 < 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 after z. In German, it sorts with a.

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 typed

So 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, i

And 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 timeZone and 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.DateTimeFormat is by far the most used: note timestamps, version history, meeting times, admin tables.
  • Intl.Segmenter shows up wherever text has to be split by real characters or words.
  • Intl.RelativeTimeFormat gives “x minutes ago” labels, and Intl.Collator sorts names the way people expect.
  • The UI is translated with next-intl, which uses ICU MessageFormat for plurals and placeholders.

Gotchas

  • A Date has 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. toLocaleString inside a loop is slow.
  • .length and .slice work on UTF-16 code units, which breaks emoji. Use Segmenter for user-visible characters.
  • Default sort() isn’t alphabetical for people. Use a Collator, with numeric: true for 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 formatToParts to style pieces. Don’t split formatted strings.

Practice

  1. Format one date in en-AU, en-US, de-DE, ko-KR and ar-EG with dateStyle: "full". Then show the same moment in three time zones.
  2. Write timeAgo() and test it around the boundaries (59 s, 60 s, 23 h, 24 h).
  3. Sort ["file10.md", "File2.md", "file1.md", "éclair.md"] with default sort() and with a numeric, base-sensitivity Collator.
  4. Write a truncate(text, n) that never cuts an emoji in half.
  5. Benchmark toLocaleDateString with options inside a 10,000-item loop vs one reused DateTimeFormat.

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)