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.
Recipe
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:
- Billing cycles, SLA deadlines, and report windows in local time
- Converting user-selected "9:00 AM Chicago" to UTC for storage
- DST transition weeks (spring forward, fall back)
- date-fns when no zone math is needed (durations, business days in UTC)
Working Example
// 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:
- Postgres:
timestamptzfor instants;textordateonly when truly calendar-local - Never store "EST" abbreviations - use IANA
America/New_York - Cron jobs: document zone in job definition and runbook
Library Split
| 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 |
- Greenfield services may use Luxon only and skip date-fns to reduce overlap.
- Do not add moment - unmaintained and mutable API.
Anti-patterns
// 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");FAQs
Luxon only or date-fns too?
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.
How do I test DST edges?
Fixture dates: second Sunday in March and first Sunday in November for US zones. Assert UTC output, not formatted strings.
JavaScript Date in APIs?
Serialize ISO 8601 strings in JSON, not Date objects (they become UTC strings anyway). Document that API consumers must send offset or Z.
Related
- Essential Libraries Basics - one library per concern
- Scheduling & Cron - repeatable jobs with zones
- zod - validate datetime strings at boundaries
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.