Internationalisation with Intl

JavaScript · Lesson 54 of 61 9 min read Updated

An app used across India has to speak to people in Hindi, Tamil, Bengali and English, and its numbers must look right: ₹12,50,000, not ₹1,250,000. Writing that formatting by hand is slow and full of bugs. JavaScript has a built-in object, Intl (short for internationalisation), that already knows the rules for hundreds of languages and regions.

You met Intl.NumberFormat and Intl.DateTimeFormat in Dates & Intl Formatting. This lesson covers the whole family: compact numbers and units, date ranges, "3 hours ago", correct plurals, lists like "Aarav, Priya and Kabir", sorting names properly and splitting text into real characters and words.

Locales and the Intl pattern

Every Intl tool takes a locale: a language code, optionally followed by a region, such as "en-IN" (English, India), "hi-IN" (Hindi, India), "ta-IN" (Tamil, India) or "en-US". These are standard BCP 47 language tags.

All the tools follow the same pattern: create a formatter once with a locale and an options object, then call its method as many times as you like. Creating a formatter does some work, so reuse it instead of creating a new one inside a loop.

JavaScript
const rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" });
rupees.format(1299);   // "₹1,299.00"
rupees.format(250000); // "₹2,50,000.00"

Some useful details:

  • Pass undefined as the locale to use the visitor's own browser language. This is usually right for a public website.
  • Pass an array such as ["ta-IN", "en-IN"] to give fallbacks: the browser uses the first one it supports.
  • A locale can carry extra settings after -u-. For example, "hi-IN-u-nu-deva" asks for Devanagari digits (४५,६७,८९०).
  • formatter.resolvedOptions() shows the locale and options the browser actually chose.

Numbers: compact, percent and units

Intl.NumberFormat does far more than currency:

JavaScriptRun it yourself
const inr = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" });
console.log(inr.format(4567890.5));

const compact = new Intl.NumberFormat("en-IN", { notation: "compact" });
console.log(compact.format(1500), compact.format(250000), compact.format(12500000));

const percent = new Intl.NumberFormat("en-IN", { style: "percent", maximumFractionDigits: 1 });
console.log(percent.format(0.8725));

const speed = new Intl.NumberFormat("en-IN", { style: "unit", unit: "kilometer-per-hour" });
console.log(speed.format(120));

const litres = new Intl.NumberFormat("en-IN", { style: "unit", unit: "liter", unitDisplay: "long" });
console.log(litres.format(2.5));

const change = new Intl.NumberFormat("en-IN", { signDisplay: "always" });
console.log(change.format(5), change.format(-3));

console.log(new Intl.NumberFormat("hi-IN-u-nu-deva").format(4567890));
console.log(new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(1299.5));
Output
₹45,67,890.50
1.5K 2.5L 1.3Cr
87.3%
120 kph
2.5 litres
+5 -3
४५,६७,८९०
1.299,50 €
  • notation: "compact" gives short forms, and with "en-IN" it uses L for lakh and Cr for crore. Useful for views, followers and prices on small screens.
  • style: "percent" multiplies by 100 for you, so pass 0.8725, not 87.25.
  • style: "unit" knows units such as kilometer, kilogram, liter, celsius and megabyte.
  • The last line shows why you never hard-code separators: Germany uses . for thousands and , for decimals.

formatToParts() returns the pieces separately, such as { type: "currency", value: "₹" }, which helps when you want to style the symbol differently from the digits.

Dates: styles, ranges and parts

Intl.DateTimeFormat can format in any language with the ready-made dateStyle and timeStyle options, and it can format a range between two dates, writing shared parts only once:

JavaScriptRun it yourself
const start = new Date(Date.UTC(2026, 11, 24, 4, 30)); // 24 Dec 2026, 10:00 am in India
const end = new Date(Date.UTC(2027, 0, 2, 4, 30));
const india = { timeZone: "Asia/Kolkata" };

const medium = new Intl.DateTimeFormat("en-IN", { ...india, dateStyle: "medium" });
console.log(medium.formatRange(start, end));

const short = new Intl.DateTimeFormat("en-IN", { ...india, day: "numeric", month: "short" });
console.log(short.formatRange(start, new Date(Date.UTC(2026, 11, 28))));

const hindi = new Intl.DateTimeFormat("hi-IN", { ...india, dateStyle: "full" });
console.log(hindi.format(start));

const parts = new Intl.DateTimeFormat("en-IN", { ...india, weekday: "long", day: "numeric", month: "long" });
console.log(parts.formatToParts(start).map((p) => `${p.type}=${p.value}`).join(" | "));
Output
24 Dec 2026 – 2 Jan 2027
24–28 Dec
गुरुवार, 24 दिसंबर 2026
weekday=Thursday | literal=,  | day=24 | literal=  | month=December

The second range is shorter because both dates share the month and year. The separator and spacing come from the locale data (an en dash here, and the exact spaces can differ between browsers and Node.js), so let formatRange() build ranges instead of joining strings with + " - " +.

Relative time: "3 hours ago"

Intl.RelativeTimeFormat turns a number and a unit into words. With numeric: "auto", it uses natural words such as "yesterday" and "tomorrow" where the language has them. It does not calculate the difference for you, so a small helper picks the best unit:

JavaScriptRun it yourself
const rtf = new Intl.RelativeTimeFormat("en-IN", { numeric: "auto" });
console.log(rtf.format(-1, "day"), "|", rtf.format(1, "day"), "|", rtf.format(2, "week"));

const hindi = new Intl.RelativeTimeFormat("hi-IN", { numeric: "auto" });
console.log(hindi.format(-1, "day"), "|", hindi.format(3, "hour"));

function timeAgo(date, now = new Date()) {
  const seconds = Math.round((date - now) / 1000);
  const units = [
    ["year", 365 * 24 * 3600],
    ["month", 30 * 24 * 3600],
    ["week", 7 * 24 * 3600],
    ["day", 24 * 3600],
    ["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");
}

const now = new Date(Date.UTC(2026, 9, 30, 12, 0));
console.log(timeAgo(new Date(Date.UTC(2026, 9, 30, 11, 15)), now));
console.log(timeAgo(new Date(Date.UTC(2026, 9, 29, 9, 0)), now));
console.log(timeAgo(new Date(Date.UTC(2026, 10, 2, 9, 0)), now));
Output
yesterday | tomorrow | in 2 weeks
कल | 3 घंटे में
45 minutes ago
yesterday
in 3 days

Negative numbers mean the past and positive numbers the future. The helper treats a month as 30 days, which is fine for "posted 2 months ago" labels.

Plurals and ordinals: PluralRules

"1 seats left" looks careless, and plural rules differ between languages. Intl.PluralRules tells you which plural category a number belongs to in a language: "zero", "one", "two", "few", "many" or "other". English only uses "one" and "other" for counting, but uses four categories for ordinals (1st, 2nd, 3rd, 4th):

JavaScriptRun it yourself
const plural = new Intl.PluralRules("en-IN");
const seats = (n) => `${n} ${plural.select(n) === "one" ? "seat" : "seats"} left`;
console.log(seats(1), "|", seats(0), "|", seats(5));

const ordinal = new Intl.PluralRules("en-IN", { type: "ordinal" });
const suffix = { one: "st", two: "nd", few: "rd", other: "th" };
const rank = (n) => n + suffix[ordinal.select(n)];
console.log([1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 101, 111].map(rank).join(" "));
Output
1 seat left | 0 seats left | 5 seats left
1st 2nd 3rd 4th 11th 12th 13th 21st 22nd 23rd 101st 111th

The ordinal rules handle the tricky cases (11th, 12th and 13th, but 21st, 22nd and 23rd) that hand-written if chains usually get wrong. In a multilingual app, keep a message for each category in each language and let select() choose.

Lists, names and display names

Intl.ListFormat joins items the way each language does. Intl.DisplayNames gives the name of a language, region or currency from its code.

JavaScriptRun it yourself
const and = new Intl.ListFormat("en-IN", { type: "conjunction" });
console.log(and.format(["Aarav", "Priya", "Kabir"]));
console.log(and.format(["Meera", "Farhan"]));
console.log(new Intl.ListFormat("en-US").format(["Aarav", "Priya", "Kabir"]));
console.log(new Intl.ListFormat("en-IN", { type: "disjunction" }).format(["UPI", "card", "cash"]));
console.log(new Intl.ListFormat("hi-IN").format(["आरव", "प्रिया", "कबीर"]));

const regions = new Intl.DisplayNames(["en"], { type: "region" });
const languages = new Intl.DisplayNames(["en"], { type: "language" });
const currencies = new Intl.DisplayNames(["en"], { type: "currency" });
console.log(regions.of("AE"), "|", languages.of("ta"), "|", currencies.of("INR"));
Output
Aarav, Priya and Kabir
Meera and Farhan
Aarav, Priya, and Kabir
UPI, card or cash
आरव, प्रिया, और कबीर
United Arab Emirates | Tamil | Indian Rupee

Notice that American English adds a comma before "and" (the serial comma), while Indian English does not. Intl follows each locale's convention so you do not have to.

Sorting and comparing: Collator

The default sort() compares UTF-16 code units, so all capital letters come before all small letters, accented letters go to the end, and "Class 10" sorts before "Class 2". Intl.Collator compares strings the way people expect:

JavaScriptRun it yourself
const files = ["Class 10.pdf", "class 2.pdf", "Class 1.pdf", "Zebra.txt", "apple.txt", "éclair.txt"];
console.log([...files].sort());

const collator = new Intl.Collator("en-IN", { numeric: true, sensitivity: "base" });
console.log([...files].sort(collator.compare));

console.log(collator.compare("Rohtak", "ROHTAK"));
console.log(["कबीर", "आरव", "अनन्या", "प्रिया"].sort(new Intl.Collator("hi").compare));
Output
[
  'Class 1.pdf',
  'Class 10.pdf',
  'Zebra.txt',
  'apple.txt',
  'class 2.pdf',
  'éclair.txt'
]
[
  'apple.txt',
  'Class 1.pdf',
  'class 2.pdf',
  'Class 10.pdf',
  'éclair.txt',
  'Zebra.txt'
]
0
[ 'अनन्या', 'आरव', 'कबीर', 'प्रिया' ]
  • numeric: true compares runs of digits as numbers, so 2 comes before 10.
  • sensitivity: "base" ignores case and accents, so compare("Rohtak", "ROHTAK") returns 0 (equal). That makes it a good case-insensitive search check.
  • collator.compare returns a negative number, 0 or a positive number, exactly what sort() expects.

For a single comparison, a.localeCompare(b, "en-IN", options) does the same job, but a reused Collator is faster when sorting long lists.

Splitting text: Segmenter

"नमस्ते".length is 6, but a reader sees three letters: न, म and स्ते. JavaScript strings count UTF-16 code units, and many Indian-language "letters" are built from several of them. Intl.Segmenter splits text into what people actually see as characters (graphemes), into words, or into sentences.

JavaScriptRun it yourself
const word = "नमस्ते";
const letters = new Intl.Segmenter("hi", { granularity: "grapheme" });
console.log(word.length, [...letters.segment(word)].map((s) => s.segment));

const words = new Intl.Segmenter("en", { granularity: "word" });
const sentence = "Train 12951 leaves Mumbai at 5:00 pm, doesn't it?";
const onlyWords = [...words.segment(sentence)].filter((s) => s.isWordLike).map((s) => s.segment);
console.log(onlyWords.length, onlyWords.join(" / "));
Output
6 [ 'न', 'म', 'स्ते' ]
10 Train / 12951 / leaves / Mumbai / at / 5 / 00 / pm / doesn't / it

Use graphemes for character limits ("max 20 letters") and for cutting text without breaking a letter in half. Word segmentation ignores punctuation and also works for languages such as Thai and Japanese that do not put spaces between words, where split(" ") is useless.

Common mistakes

  • Creating a new formatter inside a loop. Create it once and call .format() many times.
  • Passing 87 to a percent formatter. It multiplies by 100; pass 0.87.
  • Expecting identical text in every browser. The locale data comes from the browser and can differ slightly between versions. Never compare formatted strings in your program logic; compare the numbers or dates.
  • Sorting with plain sort() for names shown to users. Use Intl.Collator.
  • Counting letters with .length. Use Intl.Segmenter with "grapheme" for user-visible length.

Key points

  • Intl formats and compares text for any locale such as "en-IN", "hi-IN" or "ta-IN"; pass undefined to use the visitor's language.
  • NumberFormat handles currency, lakh and crore grouping, compact forms (2.5L, 1.3Cr), percentages and units.
  • DateTimeFormat has dateStyle, timeStyle, formatRange and formatToParts; RelativeTimeFormat writes "yesterday" and "in 3 days".
  • PluralRules chooses plural and ordinal forms; ListFormat joins lists; DisplayNames names languages, regions and currencies.
  • Collator sorts naturally (numeric: true) and compares ignoring case (sensitivity: "base").
  • Segmenter splits text into visible characters, words or sentences.

Exercise

A food delivery app in Kochi shows a summary card. Given orders = 1250000, revenue = 98765432.5 (rupees), rating = 0.946 (as a fraction) and cities = ["Kochi", "Pune", "Jaipur", "Lucknow"], print four lines using Intl: the orders in compact form, the revenue in rupees with no paise, the rating as a percentage with one decimal place, and "Now in Kochi, Pune, Jaipur and Lucknow". Then print the ordinal form of 22 ("22nd").

Show solution
JavaScript
const orders = 1250000;
const revenue = 98765432.5;
const rating = 0.946;
const cities = ["Kochi", "Pune", "Jaipur", "Lucknow"];

const compact = new Intl.NumberFormat("en-IN", { notation: "compact" });
const rupees = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR", maximumFractionDigits: 0 });
const percent = new Intl.NumberFormat("en-IN", { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1 });
const list = new Intl.ListFormat("en-IN", { type: "conjunction" });
const ordinal = new Intl.PluralRules("en-IN", { type: "ordinal" });
const suffix = { one: "st", two: "nd", few: "rd", other: "th" };

console.log(`Orders: ${compact.format(orders)}`);
console.log(`Revenue: ${rupees.format(revenue)}`);
console.log(`Rating: ${percent.format(rating)}`);
console.log(`Now in ${list.format(cities)}`);
console.log(22 + suffix[ordinal.select(22)]);
Output
Orders: 13L
Revenue: ₹9,87,65,433
Rating: 94.6%
Now in Kochi, Pune, Jaipur and Lucknow
22nd

Each formatter is created once with the "en-IN" locale, so the grouping, symbols and list style all follow Indian English. Compact notation rounds 12.5 lakh to "13L", and the rupee amount is rounded to the nearest whole rupee.

Quick Quiz

1. What does new Intl.NumberFormat("en-IN", { notation: "compact" }).format(250000) give?

2. Why should you create an Intl formatter once and reuse it?

3. Which option makes Intl.Collator sort "file 2" before "file 10"?

4. Why can "नमस्ते".length (6) be misleading?

Learn JavaScript with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call