Headline: Temporal is the TC39 API that replaces JavaScript's
Dateobject with immutable, time-zone-aware types. The migration is mechanical almost everywhere, except at two boundaries: a Temporal object cannot be passed as a prop from a Server Component to a Client Component, and it has to become a string again before it reaches a database driver.
I have written the same date bug at least four times in my career. A user in Cairo files a report at 22:30 on the 22nd, the serverless function runs in UTC, and the dashboard files it under the 21st. Every time, the fix was a patch on top of Date — a helper, a library, an offset subtracted somewhere hopeful. This year I stopped patching and moved a Next.js app's date handling onto Temporal instead.
Key takeaways
-
Temporal replaces
Date, notIntl. Temporal owns arithmetic, comparison, and time zones;Intl.DateTimeFormatstill does the human-facing formatting, and every Temporal object exposestoLocaleString(). -
Choose the Temporal type by what the value means. A birthday is a
Temporal.PlainDate, an audit timestamp is aTemporal.Instant, and "3pm in Cairo" is aTemporal.ZonedDateTime. -
Temporal objects are class instances, so React cannot serialize them across the RSC boundary. Call
.toString()in the Server Component and.from()in the Client Component. -
Every Temporal object is immutable.
.add(),.subtract(), and.with()return new objects, and===never compares two Temporal values correctly — use.equals()or the staticcompare(). -
Install the
temporal-polyfillpackage today. Native support across browsers and Node is still uneven, and the polyfill lets you write the final API now and delete one import later.
What does Temporal actually replace?
Temporal replaces the JavaScript Date object — the mutable, millisecond-based, single-time-zone type that has shipped essentially unchanged since 1995. Date has three defects that Temporal removes outright.
First, Date is mutable: d.setDate(d.getDate() + 1) edits an object that other code may still be holding. Second, Date knows exactly two time zones — UTC and whatever the host machine reports — so a function running in UTC and a browser running in Africa/Cairo disagree about what "today" means. Third, Date parsing is inconsistent by specification:
new Date('2026-08-22'); // UTC midnight — the date-only form is parsed as UTC
new Date('2026-08-22T00:00:00'); // local midnight — a date-time with no offset is local
Those two lines can differ by hours, and nothing in the code says so. Temporal turns the distinction into a type: Temporal.PlainDate.from('2026-08-22') is a calendar date with no instant attached, and Temporal.Instant.from('2026-08-22T00:00:00Z') is an exact point on the timeline. You cannot accidentally use one where you meant the other.
Which Temporal type should I use for each field?
Pick the Temporal type from what the value means to the business, not from the database column it happens to live in.
| What the value means | Temporal type | String form |
|---|---|---|
| Birthday, invoice due date | Temporal.PlainDate |
2026-08-22 |
| Store opening hour | Temporal.PlainTime |
09:00:00 |
| "Meeting at 3pm in Cairo" | Temporal.ZonedDateTime |
2026-08-22T15:00:00+03:00[Africa/Cairo] |
created_at, audit log entry |
Temporal.Instant |
2026-08-22T12:00:00Z |
| Session length, cache TTL | Temporal.Duration |
PT2H30M |
| Card expiry | Temporal.PlainYearMonth |
2026-08 |
Temporal.Now is the entry point for the current moment: Temporal.Now.instant(), Temporal.Now.zonedDateTimeISO(zone), and Temporal.Now.plainDateISO(zone). That last signature is the API doing its job — asking for today's date forces you to answer "today according to whom?", which is exactly the question new Date() lets you skip.
Why did my "is it the same day?" check break for users in another time zone?
A same-day comparison breaks because two instants only fall on the same calendar day relative to a specific time zone, and Date.prototype.toDateString() silently picks the host machine's zone. On a serverless function running in UTC, an event at 22:30 in Cairo has already rolled over to tomorrow.
// Wrong: the server's time zone decides what a "day" is
const sameDay = a.toDateString() === b.toDateString();
// Right: name the zone the question is being asked in
const zone = 'Africa/Cairo';
const sameDay = a.toZonedDateTimeISO(zone).toPlainDate()
.equals(b.toZonedDateTimeISO(zone).toPlainDate());
The same explicitness shows up in arithmetic across a daylight-saving transition, where Temporal.ZonedDateTime separates calendar units from exact units:
const zdt = Temporal.ZonedDateTime.from('2026-03-28T09:00[Europe/Berlin]');
zdt.add({ days: 1 }); // 2026-03-29T09:00 — same wall clock, 23 real hours later
zdt.add({ hours: 24 }); // 2026-03-29T10:00 — 24 exact hours, different wall clock
Both answers are correct; they answer different questions. "Same time tomorrow" is { days: 1 }. "Twenty-four hours from now" is { hours: 24 }. Date could not express the difference at all.
Can I pass a Temporal object from a Server Component to a Client Component?
No. React rejects class instances at the Server-to-Client boundary with the error Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Every Temporal type is a class, so a Temporal value has to be serialized to a string before it becomes a prop.
// app/invoices/page.tsx — Server Component
import { Temporal } from 'temporal-polyfill';
export default async function Page() {
const invoice = await getInvoice();
const due = Temporal.PlainDate.from(invoice.dueDate);
// return <DueBadge due={due} />; ✗ Temporal.PlainDate is a class instance
return <DueBadge due={due.toString()} />; // ✓ "2026-08-22"
}
'use client';
import { Temporal } from 'temporal-polyfill';
export function DueBadge({ due }: { due: string }) {
const date = Temporal.PlainDate.from(due);
const overdue = Temporal.PlainDate.compare(date, Temporal.Now.plainDateISO()) < 0;
return <span data-overdue={overdue}>{date.toLocaleString('en-GB')}</span>;
}
This one surprised me, because React's serializer does support Date — it is one of the few built-ins on the allowlist. Swapping Date for Temporal therefore breaks props that used to work without a word of warning. The consolation is that the string form carries more information than what it replaced: PlainDate.toString() emits 2026-08-22, and ZonedDateTime.toString() emits 2026-08-22T15:00:00+03:00[Africa/Cairo], which round-trips through from() with the zone intact. An epoch number in a JSON payload can never do that.
How do I store Temporal values in Postgres and read them back?
Store an instant in timestamptz, a calendar date in date, and the user's IANA time zone in its own text column whenever the wall-clock intent matters. Postgres has no column type that carries a time zone despite the name — timestamptz normalizes to UTC on write.
// node-postgres and Drizzle hand back a JS Date for timestamptz — convert at the edge
const createdAt = Temporal.Instant.fromEpochMilliseconds(row.created_at.getTime());
// Rebuild the user's wall clock from instant + stored zone
const local = createdAt.toZonedDateTimeISO(row.time_zone); // 'Africa/Cairo'
One precision detail caught me out: Temporal.Instant keeps nanoseconds, Postgres timestamptz keeps microseconds, and JS Date keeps milliseconds. A value that travels Temporal → Date → Postgres → Temporal is not always the value you started with. If exact round-trips matter to a test, round before writing with instant.round({ smallestUnit: 'microsecond' }) so the truncation is a decision you made rather than one the driver made for you.
For anything scheduled in the future — "send this reminder at 9am local, every week" — store a PlainDateTime plus the IANA zone rather than an instant. A stored instant freezes today's UTC offset, so the reminder silently shifts by an hour the next time that zone's DST rules change.
Should I delete date-fns, Day.js, or Luxon now?
Not in one commit. Temporal covers what those libraries exist to cover, but the honest comparison is narrower than "Temporal wins".
-
date-fns — replaceable for arithmetic and comparison. Its tree-shaken functions all operate on
Date, so mixed code paths need adapters for the length of the migration. -
Day.js and Moment — replaceable outright. Both wrap
Dateand inherit its time-zone model; Day.js needs itstimezoneplugin to do whatTemporal.ZonedDateTimedoes natively. - Luxon — the closest in spirit, since Luxon and the Temporal proposal share an author and an immutable, zone-aware model. Migration is mostly renaming.
-
Intl.DateTimeFormat — keep it. Temporal deliberately does not format for humans; it hands off to
Intl.
The migration order that worked for me was to convert at the edges first: parse every inbound string into a Temporal type at the API or form boundary, format back to a string at the render or database boundary, and let the middle of the app stop touching Date entirely. A Zod schema is a good place to put that parse — z.string().transform((s) => Temporal.PlainDate.from(s)) gives you a validated Temporal value and a real error message when the string is malformed.
On shipping it: Temporal is a TC39 Stage 3 proposal and browsers have started enabling it, with Firefox shipping it unflagged first and Safari following, while Chromium and Node are still catching up. Because coverage is uneven, install temporal-polyfill (the compact implementation) or @js-temporal/polyfill (the reference one), import it from one shared module, and look at your own bundle analyzer before assuming the weight is free.
FAQ
Q: Is Temporal a drop-in replacement for Date?
A: No. Temporal adds a separate global namespace and does not change Date at all. Existing code keeps working, and you convert between the two with Temporal.Instant.fromEpochMilliseconds(date.getTime()) and new Date(instant.epochMilliseconds).
Q: Why can't I compare two Temporal values with === or <?
A: Temporal values are objects, so === compares references rather than calendar values. Use a.equals(b) for equality and the static comparator Temporal.PlainDate.compare(a, b), which returns -1, 0, or 1 and can be passed straight to Array.prototype.sort.
Q: Does Temporal handle daylight saving time correctly?
A: Temporal.ZonedDateTime does, using the IANA time zone database. When a local time is ambiguous or does not exist because of a DST shift, from() accepts a disambiguation option of 'compatible', 'earlier', 'later', or 'reject', so the behaviour is a decision instead of an accident.
Q: Can I use Temporal in Node.js and inside Server Components?
A: Yes, through a polyfill. Import temporal-polyfill in server code exactly as you would in the browser — Temporal works fine inside Server Components, Route Handlers, and Server Actions, and only the props boundary needs strings.
Q: Should I store an instant or a wall-clock time in the database?
A: Store an instant in timestamptz for things that already happened, such as created_at. Store a PlainDateTime plus an IANA zone for things scheduled in the future, so a change to that zone's DST rules moves the event along with the user's clock.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)