# Intl API Guide: Format Dates, Numbers & Currency in JavaScript

> Stop hand-formatting prices and dates. A practical guide to Intl.NumberFormat, DateTimeFormat, RelativeTimeFormat, ListFormat, and DisplayNames, with real output for every major locale and the production bugs they prevent.
- **Author**: Mohit Sapat
- **Published**: 2026-10-05
- **URL**: https://www.babelize.co/blogs/javascript-intl-api-dates-numbers-currencies

---

<p class="lead">
    Translating your strings is half of localization. The other half is every number, price, date, and list your app prints. The JavaScript <code>Intl</code> API handles all of it with zero dependencies, using the same Unicode CLDR data that powers Android, iOS, and ICU. This guide covers the formatters you will actually use, the output they produce in real locales, and the bugs that show up when you skip them.
</p>

<h2>Why Hand-Rolled Formatting Breaks</h2>

<p>
    Most apps start with something like <code>`$${price.toFixed(2)}`</code> and <code>`${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`</code>. Both are correct for exactly one country. Here is the same number, <code>1234567.891</code>, formatted for a handful of locales by <code>Intl.NumberFormat</code>:
</p>

<table>
    <thead>
        <tr>
            <th>Locale</th>
            <th>Output</th>
            <th>What changed</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td><code>en-US</code></td>
            <td>1,234,567.891</td>
            <td>Comma groups, period decimal</td>
        </tr>
        <tr>
            <td><code>de-DE</code></td>
            <td>1.234.567,891</td>
            <td>Separators swapped</td>
        </tr>
        <tr>
            <td><code>fr-FR</code></td>
            <td>1 234 567,891</td>
            <td>Narrow no-break space (U+202F) groups</td>
        </tr>
        <tr>
            <td><code>de-CH</code></td>
            <td>1'234'567.891</td>
            <td>Apostrophe groups</td>
        </tr>
        <tr>
            <td><code>en-IN</code></td>
            <td>12,34,567.891</td>
            <td>Lakh grouping: 3 digits, then 2</td>
        </tr>
        <tr>
            <td><code>ar-EG</code></td>
            <td>١٬٢٣٤٬٥٦٧٫٨٩١</td>
            <td>Arabic-Indic digits by default</td>
        </tr>
    </tbody>
</table>

<p>
    A German user reading <code>1,234</code> sees "one point two three four", not "one thousand two hundred thirty-four." That is not a cosmetic issue on an invoice. None of these rules belong in your codebase. They live in the <a href="https://cldr.unicode.org/" target="_blank" rel="noopener noreferrer">Unicode CLDR</a>, and every modern browser and Node.js release ships it.
</p>

<h2>Numbers: Intl.NumberFormat</h2>

<pre><code>const nf = new Intl.NumberFormat("de-DE");
nf.format(1234567.891); // "1.234.567,891"

// Percentages take a ratio, not a percentage
new Intl.NumberFormat("en", { style: "percent", maximumFractionDigits: 1 }).format(0.256);
// "25.6%"
new Intl.NumberFormat("de", { style: "percent" }).format(0.25);
// "25 %"  (German puts a space before the sign)

// Units, localized and pluralized for you
new Intl.NumberFormat("de", {
  style: "unit",
  unit: "kilometer-per-hour",
  unitDisplay: "long",
}).format(50);
// "50 Kilometer pro Stunde"

// Signs for deltas and changelogs
new Intl.NumberFormat("en-US", { signDisplay: "exceptZero" }).format(5);
// "+5"</code></pre>

<h3>Compact notation for dashboards</h3>

<p>
    Shortening large numbers is a classic place where English assumptions leak. Thousands and millions are not universal units: Japanese groups by ten-thousands (万) and Hindi by lakhs.
</p>

<pre><code>const compact = (locale) =>
  new Intl.NumberFormat(locale, { notation: "compact" }).format(1234567);

compact("en"); // "1.2M"
compact("de"); // "1,2 Mio."
compact("ja"); // "123万"
compact("hi"); // "12 लाख"</code></pre>

<h2>Currency: Where the Real Money Bugs Live</h2>

<p>
    Currency formatting has three independent inputs, and mixing them up is the most common pricing-page bug:
</p>

<ul>
    <li><strong>The currency</strong> (<code>EUR</code>): what the money actually is. This comes from your data, never from the user's locale.</li>
    <li><strong>The locale</strong> (<code>fr-FR</code>): how to write it, meaning symbol position, separators, and spacing.</li>
    <li><strong>The precision</strong>: how many minor units the currency has.</li>
</ul>

<pre><code>const price = (locale, currency, amount) =>
  new Intl.NumberFormat(locale, { style: "currency", currency }).format(amount);

price("en-US", "EUR", 1234.5); // "EUR 1,234.50"
price("de-DE", "EUR", 1234.5); // "1.234,50 EUR "
price("fr-FR", "EUR", 1234.5); // "1 234,50 EUR "
price("ja-JP", "JPY", 1234.5); // "￥1,235"   (yen has no minor unit)
price("en-CA", "USD", 5);      // "US$5.00"  (disambiguated from CAD)</code></pre>

<p>
    Notice the last line. A Canadian reading "$5.00" assumes Canadian dollars, so the formatter adds the "US" prefix automatically. Hardcoding a <code>$</code> throws that context away.
</p>

<h3>Do not hardcode two decimal places</h3>

<p>
    Japanese yen and Korean won have zero decimal places. Kuwaiti dinar and Bahraini dinar have three. If you store money as integer minor units (which you should), ask the formatter how many digits the currency uses instead of dividing by 100:
</p>

<pre><code>function fromMinorUnits(amountMinor, currency, locale) {
  const nf = new Intl.NumberFormat(locale, { style: "currency", currency });
  const digits = nf.resolvedOptions().maximumFractionDigits; // JPY: 0, USD: 2, KWD: 3
  return nf.format(amountMinor / 10 ** digits);
}

fromMinorUnits(1999, "USD", "en-US"); // "$19.99"
fromMinorUnits(1999, "JPY", "ja-JP"); // "￥1,999"
fromMinorUnits(1999, "KWD", "en-US"); // "KWD 1.999"</code></pre>

<p>
    Ranges also have a dedicated API, which avoids repeating the symbol awkwardly in some locales:
</p>

<pre><code>new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).formatRange(10, 20);
// "$10.00 - $20.00"</code></pre>

<h2>Dates and Times: Intl.DateTimeFormat</h2>

<p>
    <code>03/04/2026</code> is March 4th in the United States and April 3rd in most of the rest of the world. Never build date strings by hand. Use <code>dateStyle</code> and <code>timeStyle</code> to get the conventional format for each locale:
</p>

<pre><code>const d = new Date(Date.UTC(2026, 2, 4, 15, 30));
const fmt = (locale) =>
  new Intl.DateTimeFormat(locale, {
    dateStyle: "medium",
    timeStyle: "short",
    timeZone: "UTC",
  }).format(d);

fmt("en-US"); // "Mar 4, 2026, 3:30 PM"
fmt("en-GB"); // "4 Mar 2026, 15:30"
fmt("de-DE"); // "04.03.2026, 15:30"
fmt("fr-FR"); // "4 mars 2026, 15:30"
fmt("ja-JP"); // "2026/03/04 15:30"</code></pre>

<p>
    The locale also decides the 12-hour versus 24-hour clock, the order of day and month, and whether month names are capitalized. You get all of it for free.
</p>

<h3>Time zones are a separate decision from locale</h3>

<p>
    Locale controls <em>how</em> a moment is written. Time zone controls <em>which wall-clock time</em> it maps to. A user in India with their browser set to <code>en-US</code> still lives in <code>Asia/Kolkata</code>. Store timestamps in UTC, and pass the user's zone explicitly when you render:
</p>

<pre><code>new Intl.DateTimeFormat("en-US", {
  dateStyle: "full",
  timeStyle: "long",
  timeZone: "Asia/Kolkata",
}).format(d);
// "Wednesday, March 4, 2026 at 9:00:00 PM GMT+5:30"

// The same instant is already the next day in New Zealand
new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "Pacific/Auckland" }).format(d);
// "Thursday, March 5, 2026"</code></pre>

<p>
    The user's zone in the browser is <code>Intl.DateTimeFormat().resolvedOptions().timeZone</code>. Save it on their profile so emails and server-rendered pages use it too.
</p>

<h3>Calendars and numbering systems</h3>

<p>
    Some locales default to a calendar other than the Gregorian one. Thai, for example, uses the Buddhist era:
</p>

<pre><code>new Intl.DateTimeFormat("th-TH", { year: "numeric" }).format(new Date(2026, 5, 1));
// "พ.ศ. 2569"</code></pre>

<p>
    If your product needs a specific calendar or digit style regardless of the locale default, override it with a Unicode extension on the locale tag, for example <code>ar-EG-u-nu-latn</code> for Latin digits or <code>en-US-u-hc-h23</code> for a 24-hour clock. We explain the full tag syntax in our guide to <a href="/blogs/locale-codes-bcp-47-explained/">locale codes and BCP 47</a>.
</p>

<h2>Relative Time: "Yesterday", Not "1 Day Ago"</h2>

<pre><code>const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
rtf.format(-1, "day");  // "yesterday"
rtf.format(0, "day");   // "today"
rtf.format(2, "week");  // "in 2 weeks"

new Intl.RelativeTimeFormat("es", { numeric: "auto" }).format(-1, "day");
// "ayer"</code></pre>

<p>
    Without <code>numeric: "auto"</code> you get the more robotic "1 day ago." <code>RelativeTimeFormat</code> handles plural agreement in every language, but it does not pick the unit for you. A small helper does:
</p>

<pre><code>const UNITS = [
  ["year", 31536000], ["month", 2592000], ["week", 604800],
  ["day", 86400], ["hour", 3600], ["minute", 60], ["second", 1],
];

export function timeAgo(date, locale, now = Date.now()) {
  const seconds = Math.round((date.getTime() - now) / 1000);
  const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
  for (const [unit, size] of UNITS) {
    if (Math.abs(seconds) >= size || unit === "second") {
      return rtf.format(Math.round(seconds / size), unit);
    }
  }
}</code></pre>

<h2>Lists and Display Names</h2>

<p>
    Joining items with <code>", "</code> and <code>" and "</code> is grammar, and grammar is locale-specific. Even British and American English disagree about the Oxford comma:
</p>

<pre><code>const people = ["Alice", "Bob", "Chen"];

new Intl.ListFormat("en").format(people);    // "Alice, Bob, and Chen"
new Intl.ListFormat("en-GB").format(people); // "Alice, Bob and Chen"
new Intl.ListFormat("de").format(people);    // "Alice, Bob und Chen"
new Intl.ListFormat("en", { type: "disjunction" }).format(["A", "B", "C"]);
// "A, B, or C"</code></pre>

<p>
    <code>Intl.DisplayNames</code> replaces the hand-maintained country and language dropdown lists that every project eventually copies from a gist:
</p>

<pre><code>new Intl.DisplayNames(["en"], { type: "region" }).of("DE");      // "Germany"
new Intl.DisplayNames(["de"], { type: "region" }).of("DE");      // "Deutschland"
new Intl.DisplayNames(["en"], { type: "language" }).of("pt-BR"); // "Brazilian Portuguese"
new Intl.DisplayNames(["ja"], { type: "currency" }).of("EUR");   // "ユーロ"</code></pre>

<p>
    For a language switcher, show each language in its own name (<code>new Intl.DisplayNames([code], { type: "language" }).of(code)</code>), so a Japanese speaker who lands on your English site can still find "日本語."
</p>

<h2>Five Bugs That Ship to Production</h2>

<h3>1. Hydration mismatches in Next.js and other SSR frameworks</h3>

<p>
    <code>date.toLocaleDateString()</code> with no arguments uses the runtime's default locale and time zone. On your server that might be <code>en-US</code> and UTC. In the browser it is the user's settings. The two renders disagree and React logs a hydration error, or worse, silently shows the wrong date. Always pass the locale and time zone explicitly, from the same source on server and client.
</p>

<h3>2. Snapshot tests that compare exact strings</h3>

<p>
    French and some other locales use non-breaking and narrow non-breaking spaces (U+00A0 and U+202F), not regular spaces. A test asserting <code>"1 234,50 EUR "</code> typed with a normal space bar fails. CLDR data also changes between releases, so exact-string snapshots break on Node upgrades. Use <code>formatToParts()</code> to assert on structure instead:
</p>

<pre><code>new Intl.NumberFormat("fr-FR").formatToParts(1234.5);
// [
//   { type: "integer", value: "1" },
//   { type: "group",   value: " " },   // U+202F
//   { type: "integer", value: "234" },
//   { type: "decimal", value: "," },
//   { type: "fraction", value: "5" }
// ]</code></pre>

<h3>3. Parsing localized numbers back</h3>

<p>
    <code>Intl</code> formats but does not parse. <code>parseFloat("1.234,50")</code> returns <code>1.234</code>. For inputs, use <code>&lt;input type="number"&gt;</code> where it fits, or read the locale's separators from <code>formatToParts()</code> and normalize before parsing.
</p>

<h3>4. Creating formatters inside render loops</h3>

<p>
    Constructing an <code>Intl</code> formatter is far more expensive than calling <code>.format()</code> on one. In a table with thousands of cells, create the formatter once per locale and options combination and reuse it:
</p>

<pre><code>const cache = new Map();

export function getNumberFormat(locale, options = {}) {
  const key = locale + JSON.stringify(options);
  if (!cache.has(key)) cache.set(key, new Intl.NumberFormat(locale, options));
  return cache.get(key);
}</code></pre>

<h3>5. Deriving currency from locale</h3>

<p>
    A user with their browser set to <code>de-DE</code> may be a US customer paying in dollars. The locale says how to display money. Your billing data says which money it is. Keep them separate.
</p>

<h2>Quick Reference</h2>

<table>
    <thead>
        <tr>
            <th>You need to show</th>
            <th>Use</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Numbers, percentages, units</td>
            <td><code>Intl.NumberFormat</code></td>
        </tr>
        <tr>
            <td>Prices</td>
            <td><code>Intl.NumberFormat</code> with <code>style: "currency"</code></td>
        </tr>
        <tr>
            <td>Dates and times</td>
            <td><code>Intl.DateTimeFormat</code> with <code>dateStyle</code>/<code>timeStyle</code> and <code>timeZone</code></td>
        </tr>
        <tr>
            <td>"3 days ago", "tomorrow"</td>
            <td><code>Intl.RelativeTimeFormat</code></td>
        </tr>
        <tr>
            <td>"A, B, and C"</td>
            <td><code>Intl.ListFormat</code></td>
        </tr>
        <tr>
            <td>Country, language, currency names</td>
            <td><code>Intl.DisplayNames</code></td>
        </tr>
        <tr>
            <td>Plural categories</td>
            <td><code>Intl.PluralRules</code>, or ICU messages (see our <a href="/blogs/icu-messageformat-plurals-guide/">ICU MessageFormat guide</a>)</td>
        </tr>
        <tr>
            <td>Sorting and comparing text</td>
            <td><code>Intl.Collator</code> (see our <a href="/blogs/unicode-string-handling-javascript-i18n/">Unicode strings guide</a>)</td>
        </tr>
    </tbody>
</table>

<p>
    Every API above is supported in all evergreen browsers and current Node.js releases. The <a href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl" target="_blank" rel="noopener noreferrer">MDN Intl reference</a> lists every option.
</p>

<h2>Frequently Asked Questions</h2>

<h3>Do I need a library like Moment.js or numeral.js for localized formatting?</h3>
<p>
    No. The built-in Intl API covers number, currency, date, time, relative time, list, and plural formatting for every locale in Unicode CLDR, with no bundle cost. Libraries are still useful for date arithmetic and parsing, which Intl does not do.
</p>

<h3>How do I format currency correctly in JavaScript?</h3>
<p>
    Use <code>new Intl.NumberFormat(locale, { style: "currency", currency })</code>, where the currency code comes from your data and the locale comes from the user. The formatter handles symbol placement, separators, and the right number of decimal places for each currency.
</p>

<h3>Why does my date show differently on the server and the client?</h3>
<p>
    Calling <code>toLocaleDateString()</code> or <code>Intl.DateTimeFormat</code> without an explicit locale and time zone uses the runtime defaults, which differ between your server and the user's browser. Pass both explicitly, from the same source, during server and client rendering.
</p>

<h3>What is the difference between locale and time zone?</h3>
<p>
    The locale controls how a date is written: order, separators, month names, and 12- or 24-hour clock. The time zone controls which local time a moment maps to. They are independent, so a user can prefer English formatting while living in India.
</p>

<h3>Is the Intl API fast enough for large tables?</h3>
<p>
    Yes, as long as you create each formatter once and reuse it. Construction is the expensive step; calling <code>format()</code> on a cached formatter is cheap.
</p>

<h2>The Short Version</h2>

<p>
    Never concatenate a currency symbol, never build a date string by hand, and never assume a thousands separator. Pass a locale and, for dates, a time zone to the matching <code>Intl</code> formatter, cache it, and let CLDR handle the rules for every market you sell into.
</p>

<p>
    Formatting is the code side of localization. The strings around those numbers still need translating and keeping in sync as your product changes. <a href="/">Babelize</a> handles that part inside your Git workflow, so new copy ships in every language alongside the code that uses it.
</p>
---
- [All articles](https://www.babelize.co/blogs)