i18n Formatting: Dates, Numbers & Currency with Intl and i18next (2026 Guide)
Translation covers the words. Locale-aware formatting covers everything numeric, and it is where hand-rolled i18n quietly falls apart: 1,234.56 is 1.234,56 in German and 1 234 567,89 in French, the same day is 7/7/2026, 7.7.2026 or 2026/7/7 depending on the locale, and yen amounts have no decimal places at all. Concatenating '$' + price.toFixed(2) is a bug in most of the world.
The good news: every one of these rules already ships with the browser, and i18next exposes them directly inside your translation strings. This guide covers the Intl APIs that do the work and the i18next wiring on top.
Table of contents
- Why hand-formatting breaks
- The Intl formatting toolbox
- Numbers, percent & compact notation
- Currency
- Dates, times & relative time
- Formatting inside i18next translations
- Custom formatters
- Common mistakes
- Managing formatted strings across languages
- Frequently asked questions
Why hand-formatting breaks
Take one number, 1234567.89, and render it the way five real locales expect:
| Locale | Rendering |
|---|---|
en-US (English, US) | 1,234,567.89 |
de-DE (German) | 1.234.567,89 |
fr-FR (French) | 1 234 567,89 |
de-CH (Swiss German) | 1'234'567.89 |
en-IN (English, India) | 12,34,567.89 |
The German rendering swaps the meaning of . and , relative to English. French groups with (narrow non-breaking) spaces. India groups in lakhs and crores, so even the group sizes differ. Dates have the same problem (month/day/year vs day.month.year vs year/month/day), and currencies add symbol position and per-currency decimal rules on top.
None of this is something to hand-code per language. Like plural rules, it is all defined in the Unicode CLDR, and it is already in your runtime.
The Intl formatting toolbox
The ECMAScript Internationalization API (Intl) ships with every modern browser and Node.js, CLDR data included, so there are no locale bundles to install:
| API | What it formats | i18next format name |
|---|---|---|
Intl.NumberFormat | numbers, percent, units, compact notation, currency | number, currency |
Intl.DateTimeFormat | dates and times | datetime |
Intl.RelativeTimeFormat | "3 days ago", "in 2 hours" | relativetime |
Intl.ListFormat | "a, b, and c" | list |
Intl.PluralRules | plural categories | powers the _one / _other suffixes |
The first four are this article. The last one is its own topic, covered in the pluralization guide linked above.
Numbers, percent & compact notation
Intl.NumberFormat handles separators, percent, physical units and compact ("social media") notation, per locale:
new Intl.NumberFormat('en-US').format(1234567.89) // "1,234,567.89"
new Intl.NumberFormat('de-DE').format(1234567.89) // "1.234.567,89"
new Intl.NumberFormat('en', { style: 'percent' }).format(0.256) // "26%"
new Intl.NumberFormat('de', { style: 'percent' }).format(0.256) // "26 %"
new Intl.NumberFormat('en', { notation: 'compact' }).format(1234567) // "1.2M"
new Intl.NumberFormat('de', { notation: 'compact' }).format(1234567) // "1,2 Mio."
new Intl.NumberFormat('en', { style: 'unit', unit: 'kilometer-per-hour' }).format(88) // "88 km/h"Note the details you would never hand-code: German percent gets a (non-breaking) space before %, and the compact suffix is a locale-specific word, not always a letter.
Currency
Currency formatting is number formatting plus three locale-dependent decisions: which symbol, where it goes, and how many decimal places the currency uses.
const price = 1234.5
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price)
// "$1,234.50"
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(price)
// "1.234,50 €"
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(price)
// "¥1,235"The yen line is the one that catches people: JPY has no minor units, so Intl rounds to whole yen. price.toFixed(2) would have printed ¥1234.50, which does not exist.
One rule to internalize: the currency comes from the transaction, the formatting comes from the user's locale. A German user buying in dollars sees dollars, formatted the German way:
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'USD' }).format(price)
// "1.234,50 $"Never derive the currency code from the UI language.
Dates, times & relative time
Intl.DateTimeFormat covers the order, the separators, and the month/weekday names. The dateStyle / timeStyle presets pick a sensible per-locale layout:
const d = new Date('2026-07-07T15:30:00Z')
new Intl.DateTimeFormat('en-US').format(d) // "7/7/2026"
new Intl.DateTimeFormat('de-DE').format(d) // "7.7.2026"
new Intl.DateTimeFormat('ja-JP').format(d) // "2026/7/7"
new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }).format(d)
// "Tuesday, July 7, 2026"
new Intl.DateTimeFormat('de-DE', { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/Zurich' }).format(d)
// "7. Juli 2026 um 17:30"Formatting does not convert time zones for you; without an explicit timeZone option it renders in the runtime's zone.
For "3 days ago" / "in 2 hours" strings, Intl.RelativeTimeFormat takes a signed offset plus a unit:
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
rtf.format(-1, 'day') // "yesterday"
rtf.format(3, 'hour') // "in 3 hours"
new Intl.RelativeTimeFormat('de', { numeric: 'auto' }).format(-1, 'day') // "gestern"And Intl.ListFormat joins word lists with the right separators and conjunction per language:
new Intl.ListFormat('en').format(['HTML', 'CSS', 'JavaScript']) // "HTML, CSS, and JavaScript"
new Intl.ListFormat('de').format(['HTML', 'CSS', 'JavaScript']) // "HTML, CSS und JavaScript"Formatting inside i18next translations
Calling Intl yourself works, but it splits a sentence into code-formatted fragments and translated fragments. i18next solves this: since v21.3, the Intl formatters are built into interpolation, so the placeholder carries its format:
{
"downloads_one": "{{count, number}} download",
"downloads_other": "{{count, number}} downloads",
"price": "On sale for {{val, currency(USD)}}",
"updated": "Updated {{val, relativetime}}",
"released": "Released on {{val, datetime}}",
"authors": "By {{val, list}}"
}t('downloads', { count: 1234567 }) // "1,234,567 downloads"
t('price', { val: 42.5 }) // "On sale for $42.50"
t('updated', { val: -3 }) // "Updated 3 days ago"
t('released', { val: new Date() }) // "Released on 7/7/2026"
t('authors', { val: ['Nina', 'Marco', 'Aisha'] }) // "By Nina, Marco, and Aisha"i18next passes the active language to the right Intl API for you, and current versions cache the underlying formatter instances. The downloads key shows how formatting composes with the _one / _other plural suffixes: count selects the plural form and renders localized. And since this is plain t() interpolation, it works unchanged in react-i18next, next-i18next, and every other binding.
Format options go either inline in the key, or per call via formatParams:
{
"revenue": "Revenue: {{val, currency}}",
"score": "{{val, number(minimumFractionDigits: 2)}} points"
}t('revenue', {
val: 12345.67,
formatParams: { val: { currency: 'EUR' } }
})
// active language en → "Revenue: €12,345.67"
// active language de → "Umsatz: 12.345,67 €"Both the string and the formatting follow the active language; nothing in your component changes. Keep the resources as plain .json (format reference), and note this is interpolation-level formatting: for how entire message formats compare (ICU, Fluent, gettext, …) see I18N in the Multiverse of Formats.
Custom formatters
Anything Intl does not cover, you can register yourself and use like a built-in:
i18next.services.formatter.add('filesize', (value, lng) => {
const mb = value / (1024 * 1024)
return `${new Intl.NumberFormat(lng, { maximumFractionDigits: 1 }).format(mb)} MB`
}){ "attachment": "Attachment size: {{val, filesize}}" }If the setup work is per-locale (like constructing that Intl.NumberFormat), newer i18next versions also provide formatter.addCached, which builds the formatter once per language and reuses it. Projects started before i18next v21 may still configure the legacy interpolation.format(value, format, lng) function instead; it keeps working, but the built-ins now cover most of what it was used for (typically Moment.js or Numeral.js wrappers).
Common mistakes
- Concatenating symbols.
'$' + price.toFixed(2)gets the symbol, its position, the separators and the decimal count wrong (JPY has none). Usestyle: 'currency', always. - No locale argument.
toLocaleString()ornew Intl.NumberFormat()without a locale uses the runtime's locale, not your user's language, a classic source of server/client hydration mismatches. The i18next built-ins always pass the active language. - Deriving currency from the UI language. Currency is transaction data. Format
USDwith German separators for a German user; do not silently switch them toEUR. - Typing formats into translations. If translators write
1.234,56-style patterns into strings, every copy change risks a format bug. Keep the raw placeholder ({{val, number}}) in the string and let CLDR do the rest. - Confusing formatting with time zones.
Intl.DateTimeFormatrenders in the runtime's zone unless you passtimeZone. Formatting localizes the display, it does not move the clock. - Missing ICU data on the server. Official Node.js builds ship full ICU since v13, but custom or
small-icubuilds silently fall back to English formats, a bug you only see server-side.
Managing formatted strings across languages
The formatting itself is free, CLDR does the work. What breaks in practice is the placeholders. A translator who retypes {{val, currency}} as {{val currency}}, or translates the word relativetime into French, breaks exactly one language in production, and no test written against the English strings will catch it.
This is where a translation management system earns its place. Locize is built around the i18next message format:
- Placeholders like
{{val, datetime}}stay visible in the editor; translators move them into position instead of retyping them. - Machine and AI translation is i18next-format-aware, so placeholders survive the round trip intact.
- Keys added via saveMissing arrive with their placeholders, ready to translate.
Get the formatting from Intl, keep the strings in JSON, and adding a locale becomes a translation task, not an engineering task, which is the whole goal of i18n.
Frequently Asked Questions
Since i18next v21.3 formatting is built in: write {{val, number}}, {{val, datetime}}, {{val, currency(USD)}}, {{val, relativetime}} or {{val, list}} directly in the translation string. i18next calls the matching Intl API (Intl.NumberFormat, Intl.DateTimeFormat, …) with the active language, so the output automatically follows the locale.
Use {{val, currency(USD)}} in the translation string, or keep the string generic ({{val, currency}}) and pass the currency code at call time via formatParams. The currency code should come from your transaction data, not from the UI language: a German user can pay in USD, which renders as 1.234,50 $ with German separators and the dollar sign.
Intl is the ECMAScript Internationalization API built into every modern browser and Node.js. It provides locale-aware formatters backed by CLDR data: Intl.NumberFormat (numbers, currency, percent, units), Intl.DateTimeFormat (dates and times), Intl.RelativeTimeFormat ("3 days ago"), Intl.ListFormat (word lists) and Intl.PluralRules (plural categories). No extra library and no locale files to ship.
For formatting output: usually no. Intl.DateTimeFormat and Intl.NumberFormat cover locale-aware display without shipping locale bundles. Libraries like date-fns or Day.js remain useful for date arithmetic and parsing, and Moment.js is in maintenance mode; its own documentation recommends Intl-based alternatives.
The most common cause is calling toLocaleString() or new Intl.NumberFormat() without a locale argument: that uses the locale of the runtime, not the language the user selected, which also causes server/client hydration mismatches. The built-in i18next formatters always pass the active language for you. Also check that the value is a real number or Date object, not a string.
Intl.RelativeTimeFormat formats a numeric offset plus a unit: format(-3, "day") gives "3 days ago", format(2, "hour") gives "in 2 hours", and the numeric: "auto" option turns an offset of 1 day into "yesterday" or "tomorrow". In i18next you write {{val, relativetime}} and pass the offset; computing the offset from a timestamp stays in your code.
Yes. Register it with i18next.services.formatter.add("myformat", (value, lng, options) => …) and use {{val, myformat}} in translation strings. For per-locale setup work there is formatter.addCached, which builds the formatter once per language and reuses it, the same caching i18next applies to its built-in Intl formats.
No. Separators, symbol position and date order come from CLDR via Intl, per locale, so nobody has to type them. Translators only move placeholders like {{val, datetime}} to the right position in the sentence. A translation management system such as Locize keeps those placeholders visible and machine-translates around them without breaking them.