date-fns / Luxon
Backends fail quietly on time zones. Luxon handles IANA zones and DST; date-fns handles calendar arithmetic. Use both deliberately or standardize on Luxon alone.
Search across all documentation pages
Backends fail quietly on time zones. Luxon handles IANA zones and DST; date-fns handles calendar arithmetic. Use both deliberately or standardize on Luxon alone.
Quick-reference recipe card - copy-paste ready.
import { DateTime } from "luxon";
// Store UTC ISO in Postgres
const scheduledAtUtc = DateTime.utc(2026, 7, 9, 14, 30).toISO();
// Convert for customer-facing email (explicit zone)
const display = DateTime.fromISO(scheduledAtUtc, { zone: "utc" })
.setZone("America/New_York")
.toFormat("ff ZZZZ");
// "Jul 9, 2026, 10:30 AM EDT"import { addBusinessDays, differenceInCalendarDays } from "date-fns";
const shipDate = addBusinessDays(new Date("2026-07-09"), 3);
const daysUntilDue = differenceInCalendarDays(dueDate, new Date());When to reach for this:
// src/scheduling/appointments.ts
import { DateTime } from "luxon";
import { z } from "zod";
const bookSchema = z.object({
slotLocal: z.string().datetime({ offset: true }),
timeZone: z.string(), // IANA: America/Chicago
});
export function toUtcStorage(input: z.infer<typeof bookSchema>): string {
const local = DateTime.fromISO(input.slotLocal, { zone: input.timeZone });
if (!local.isValid) {
throw new Error(`invalid slot: ${local.invalidReason}`);
}
return local.toUTC().toISO()!;
}
export function nextBillingRunUtc(
anchorUtc: string,
customerZone: string
): string {
const local = DateTime.fromISO(anchorUtc, { zone: "utc" }).setZone(customerZone);
// Bill at 00:05 local on the 1st
const next = local.plus({ months: 1 }).startOf("month").set({
hour: 0,
minute: 5,
second: 0,
millisecond: 0,
});
return next.toUTC().toISO()!;
}// API: always return UTC + optional display hint
app.get("/appointments/:id", async (req) => {
const row = await db.getAppointment(req.params.id);
const startsAtUtc = row.starts_at; // timestamptz
const forUser = DateTime.fromISO(startsAtUtc, { zone: "utc" })
.setZone(row.user_timezone)
.toISO();
return {
startsAtUtc,
startsAtLocal: forUser,
timeZone: row.user_timezone,
};
});Database rules:
timestamptz for instants; text or date only when truly calendar-localAmerica/New_York| Task | Library | Example |
|---|---|---|
| IANA zone conversion | Luxon | setZone("Europe/Berlin") |
| DST-safe scheduling | Luxon | plus({ months: 1 }) in zone |
| Business days (UTC) | date-fns | addBusinessDays |
| Duration between dates | date-fns | differenceInMinutes |
| Format for logs | Luxon | toISO() UTC always |
// WRONG: parses as server local zone
new Date("2026-07-09 09:00:00");
// WRONG: EST is ambiguous (EST vs EDT)
const tz = "EST";
// RIGHT: explicit offset or IANA
DateTime.fromISO("2026-07-09T09:00:00-05:00");
DateTime.now().setZone("America/Chicago");If every date touches a user time zone, Luxon only is fine. Add date-fns when you need its tree-shakeable calendar helpers and all math stays UTC.
Fixture dates: second Sunday in March and first Sunday in November for US zones. Assert UTC output, not formatted strings.
Serialize ISO 8601 strings in JSON, not Date objects (they become UTC strings anyway). Document that API consumers must send offset or Z.
Stack versions: This page was written for Node.js 24.18.0 (Active LTS), npm 10+, TypeScript 5.6+, Express 5, Fastify 5, and NestJS 11.
Reviewed by Chris St. John·Last updated Jul 16, 2026