Format Dates for Multiple Locales Without External Libraries
To render one date in several locales with zero dependencies, build a cached Intl.DateTimeFormat per locale and bind an explicit timeZone โ no moment.js or date-fns required. This page is part of Cross-Browser Date Formatting Quirks.
Why this scenario is tricky
Two misconceptions cause most multi-locale bugs. The first is the locale array: passing ['de-DE', 'en-US'] to a single formatter does not produce two outputs โ the array is a fallback list, and the formatter uses the first locale the engine supports. To get N locales you need N formatters.
The second is the missing timeZone. Intl.DateTimeFormat formats from a UTC instant, so a given timestamp maps to exactly one local string per zone โ the conversion is never ambiguous. But if you omit timeZone, the formatter falls back to the host zone. On a UTC server that produces a different day or hour than the user's browser, so the same dashboard row renders inconsistently and SSR hydration mismatches. The fix is to always bind a zone explicitly and reuse the formatter, since constructing one runs a full CLDR data lookup.
The diagram below shows the correct fan-out: one UTC instant, one formatter per locale, all sharing the same explicit zone.
Minimal working solution
One formatter per locale, all sharing the same explicit zone:
const SUPPORTED_LOCALES = ['en-US', 'de-DE', 'ja-JP', 'ar-SA'] as const;
export function formatForAllLocales(
instant: Date,
timeZone: string, // Explicit zone => one output per locale.
options: Intl.DateTimeFormatOptions = { dateStyle: 'long' },
): Record<string, string> {
const out: Record<string, string> = {};
for (const locale of SUPPORTED_LOCALES) {
// One formatter per locale: the locale array is a fallback list, not a fan-out.
out[locale] = new Intl.DateTimeFormat(locale, { ...options, timeZone }).format(instant);
}
return out;
}
const event = new Date('2024-06-21T18:00:00Z');
formatForAllLocales(event, 'America/New_York');
// { 'en-US': 'June 21, 2024', 'de-DE': '21. Juni 2024',
// 'ja-JP': '2024ๅนด6ๆ21ๆฅ', 'ar-SA': 'ูขูก ููููู ูขู ูขูค' }
Full production version
For repeated rendering, memoize the formatters and validate locales up front so a bad locale string fails loudly instead of silently falling back:
type FormatterKey = string;
const formatterCache = new Map<FormatterKey, Intl.DateTimeFormat>();
function getCachedFormatter(
locale: string,
timeZone: string,
options: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
const key = `${locale}|${timeZone}|${JSON.stringify(options)}`;
let formatter = formatterCache.get(key);
if (!formatter) {
formatter = new Intl.DateTimeFormat(locale, { ...options, timeZone });
formatterCache.set(key, formatter);
}
return formatter;
}
export function formatMultiLocale(
input: Date | number,
locales: readonly string[],
timeZone: string,
options: Intl.DateTimeFormatOptions = { dateStyle: 'long', timeStyle: 'short' },
): Record<string, string> {
// Canonicalize once โ throws RangeError on an invalid locale instead of guessing.
const canonical = Intl.getCanonicalLocales([...locales]);
const instant = typeof input === 'number' ? new Date(input) : input;
if (Number.isNaN(instant.getTime())) {
throw new TypeError('formatMultiLocale: input is an Invalid Date.');
}
const out: Record<string, string> = {};
for (const locale of canonical) {
out[locale] = getCachedFormatter(locale, timeZone, options).format(instant);
}
return out;
}
To resolve a safe client zone (old WebViews can return undefined), reuse the fallback chain from the parent guide; for the full options surface see mastering Intl.DateTimeFormat options.
Verification
The same UTC instant must render the same calendar day in a fixed zone regardless of the host machine's zone:
const instant = new Date('2024-06-21T18:00:00Z'); // 14:00 in New York (EDT, UTC-4).
const r = formatMultiLocale(instant, ['en-US', 'de-DE'], 'America/New_York', { dateStyle: 'long' });
// Deterministic: bound zone makes output independent of process.env.TZ.
console.assert(r['en-US'] === 'June 21, 2024', 'en-US day must be the 21st in New York');
console.assert(r['de-DE'] === '21. Juni 2024', 'de-DE day must be the 21st in New York');
// A locale array is a fallback list, not two outputs: this produces ONE string.
const fallback = new Intl.DateTimeFormat(['xx-INVALID', 'de-DE'], { timeZone: 'UTC' }).format(instant);
console.assert(typeof fallback === 'string', 'array resolves to a single supported locale');
Common pitfalls
-
Treating the locale array as a fan-out.
// Wrong: one string in de-DE (or its fallback), NOT one per locale. new Intl.DateTimeFormat(['de-DE', 'ja-JP']).format(d); // Right: one formatter per locale. locales.map((l) => new Intl.DateTimeFormat(l, { timeZone }).format(d)); -
Creating a formatter per row.
// Wrong: new CLDR lookup every iteration. rows.map((r) => new Intl.DateTimeFormat(locale, { timeZone }).format(r.ts)); // Right: build once, reuse. const f = getCachedFormatter(locale, timeZone, {}); rows.map((r) => f.format(r.ts)); -
Omitting
timeZone. Falls back to the host zone, so a UTC server and a local browser disagree. Always bind an explicit IANA zone. -
Mixing
dateStyle/timeStylewith explicit components. CombiningdateStylewithyear/month/hourthrows aTypeError. Pick one approach per options object.
Frequently Asked Questions
Does passing a locale array format the date in multiple locales?
No. The array is a fallback list; the formatter picks the first locale the engine supports and produces a single string. To render multiple locales, create one Intl.DateTimeFormat per locale.
Is going library-free actually smaller and safe across browsers?
Yes. Intl.DateTimeFormat is native in every evergreen browser and Node.js 13+, adds 0 KB to your bundle, and receives CLDR updates with the runtime. For very old targets, load @formatjs/intl-datetimeformat via a conditional dynamic import.
Why do I get a different day on the server than in the browser?
You almost certainly omitted timeZone, so the formatter used the host zone โ UTC on the server, local in the browser. Bind an explicit IANA timeZone so a given UTC instant always renders the same calendar day.