Format a date for a user's locale
An API hands you "2026-06-12T14:30:00Z". A reader wants either "yesterday" or
"12 June 2026 at 15:30" — never the raw ISO string, and never a US format for a
German visitor.
Three modules cover it: datetime parses the string,
date decides whether it is recent enough for a relative
label, and i18n formats the rest through Intl.
The code
import { daysBetween, formatRelative } from '@rtorcato/js-common/date'
import { parseIsoDateTime } from '@rtorcato/js-common/datetime'
import { formatDateI18n } from '@rtorcato/js-common/i18n'
/**
* Formats an ISO timestamp for display.
* Returns `null` for input that is not a valid date, so the caller decides
* what to render instead of getting "Invalid Date" in the UI.
*/
export function formatTimestamp(iso: string, locale?: string, now = new Date()): string | null {
const date = parseIsoDateTime(iso)
if (!date) return null
// Yesterday / today / tomorrow read better than a full date.
if (Math.abs(daysBetween(date, now)) <= 1) return formatRelative(date, now)
return formatDateI18n(date, locale, { dateStyle: 'long', timeStyle: 'short' })
}
formatTimestamp('2026-06-11T14:30:00Z', 'en-GB', new Date('2026-06-12T09:00:00Z'))
// "yesterday"
formatTimestamp('2026-06-12T14:30:00Z', 'en-GB', new Date('2026-08-01T09:00:00Z'))
// "12 June 2026 at 15:30"
formatTimestamp('2026-06-12T14:30:00Z', 'de-DE', new Date('2026-08-01T09:00:00Z'))
// "12. Juni 2026 um 16:30"
formatTimestamp('not a date')
// null
Picking the locale
Passing locale explicitly is the reliable option — take it from the user's
account settings, an Accept-Language header, or the route.
If you have nothing, detectLanguage() gives you a fallback:
import { detectLanguage } from '@rtorcato/js-common/i18n'
const locale = user.locale ?? detectLanguage() // "en" if nothing is detectable
formatTimestamp(iso, locale)
detectLanguage drops the regionIt returns the language only — a navigator.language of "en-GB" becomes
"en", so dates format as US English. In the browser, prefer
navigator.language directly when the region matters; use detectLanguage()
for its defaultLang fallback on the server.
Leaving locale as undefined is also valid: Intl then uses the runtime's
default locale. That is the right call in a browser and the wrong one on a
server, where the runtime locale is the machine's, not the reader's.
Timezones
Intl.DateTimeFormat formats in the runtime's timezone unless you say
otherwise. On a server that is usually UTC, which is rarely what the reader
wants. Pass the zone through when you know it:
formatDateI18n(date, 'en-GB', {
dateStyle: 'long',
timeStyle: 'short',
timeZone: user.timeZone, // e.g. "Europe/Berlin"
})
For anything beyond formatting — timezone arithmetic, durations, parsing
non-ISO formats — reach for luxon or
date-fns. The date/datetime modules are
deliberately thin.
Numbers travel with dates
The same Intl treatment applies to any number rendered next to that date:
import { formatNumber } from '@rtorcato/js-common/i18n'
formatNumber(1234.56, 'de-DE') // "1.234,56"
formatNumber(0.42, 'en-US', { style: 'percent' }) // "42%"