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.

One instant, one zone, four locale outputsA single UTC instant passes through four cached Intl.DateTimeFormat instances for en-US, de-DE, ja-JP, and ar-SA, all bound to America/New_York, producing four localized strings of the same moment.UTC instant2024-06-21T18:00Zzone: America/New_Yorkformatter en-USformatter de-DEformatter ja-JPformatter ar-SAJune 21, 202421. Juni 20242024ๅนด6ๆœˆ21ๆ—ฅูขูก ูŠูˆู†ูŠูˆ ูขู ูขูค

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

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.