Temporal in Production: Replacing JavaScript Date, Fixing the Same-Day Bug, and the RSC Boundary Nobody Warns You About
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗Key takeaways
Temporal is the TC39 API that replaces JavaScript's Date object with immutable, time-zone-aware types: Temporal.PlainDate, Temporal.PlainTime, Temporal.PlainDateTime, Temporal.ZonedDateTime, Temporal.Instant, and Temporal.Duration.
Temporal replaces Date, not Intl. Temporal owns arithmetic, comparison, and time zones, while Intl.DateTimeFormat still handles human-facing formatting through each Temporal object's toLocaleString() method.
Choose the Temporal type by what the value means to the business. A birthday is a Temporal.PlainDate, an audit timestamp is a Temporal.Instant, and a meeting at 3pm in Cairo is a Temporal.ZonedDateTime.
Temporal objects are class instances, so React cannot serialize them across the React Server Components boundary. Call .toString() in the Server Component and .from() in the Client Component.
Every Temporal object is immutable, and the === operator never compares two Temporal values correctly. Use .equals() for equality and the static compare() method for sorting.
Native Temporal support across browsers and Node.js is still uneven in 2026, so we install the temporal-polyfill package and write the final API today.
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. Our team treats Temporal as the first genuine fix rather than another wrapper.
Date has three defects that Temporal removes outright. Date is mutable, so d.setDate(d.getDate() + 1) edits an object that other code may still be holding. Date knows exactly two time zones, UTC and whatever the host machine reports, so a serverless function in UTC and a browser in Africa/Cairo disagree about what today means. Date parsing is inconsistent by specification: new Date('2026-08-22') is parsed as UTC midnight because it is a date-only form, while new Date('2026-08-22T00:00:00') is parsed as local midnight because it is a date-time with no offset.
Temporal turns that 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. A PlainDate has no epochMilliseconds to read, so the two cannot be confused by accident.
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. This is the default mapping we apply on new work.
A birthday or an invoice due date is a Temporal.PlainDate, written as 2026-08-22.
A store opening hour is a Temporal.PlainTime, written as 09:00:00.
A meeting at 3pm in Cairo is a Temporal.ZonedDateTime, written as 2026-08-22T15:00:00+03:00[Africa/Cairo].
A created_at column or an audit log entry is a Temporal.Instant, written as 2026-08-22T12:00:00Z.
A session length or a cache TTL is a Temporal.Duration, written as PT2H30M.
A card expiry is a Temporal.PlainYearMonth, written as 2026-08.
Temporal.Now is the entry point for the current moment, through 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 the caller to answer today according to whom, which is exactly the question new Date() lets a team skip.
Why does an is-it-the-same-day check break across time zones?
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 the next day.
The Temporal version names the zone explicitly: convert both instants with .toZonedDateTimeISO('Africa/Cairo'), reduce each to .toPlainDate(), then compare them with .equals(). The zone is now a visible argument in the code rather than an invisible property of the machine running it.
The same explicitness appears in arithmetic across a daylight-saving transition, because Temporal.ZonedDateTime separates calendar units from exact units. Starting from 2026-03-28T09:00[Europe/Berlin], calling .add({ days: 1 }) returns 09:00 the next morning, which is 23 real hours later, while .add({ hours: 24 }) returns 10:00, which is a different wall clock. Both are correct answers to different questions, and Date could not express the difference at all.
Can a Temporal object be passed from a Server Component to a Client Component?
No. React rejects class instances at the Server-to-Client boundary with the error message: Only plain objects, and a few built-ins, can be passed to Client Components from Server Components. Every Temporal type is a class, so each Temporal value must be serialized to a string before it becomes a prop.
In practice this means calling due.toString() in the Server Component and Temporal.PlainDate.from(due) in the Client Component. This bites teams specifically because React's serializer does support Date, which sits on the small allowlist of supported built-ins, so replacing Date with Temporal breaks props that previously worked without any warning.
The string form carries more information than the value it replaces. 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 IANA zone intact. An epoch number in a JSON payload can never carry that zone.
How should Temporal values be stored in Postgres?
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 actually carries a time zone despite the name, because timestamptz normalizes to UTC on write.
Drivers such as node-postgres, and ORMs built on them such as Drizzle, hand back a JS Date for a timestamptz column, so convert at the edge with Temporal.Instant.fromEpochMilliseconds(row.created_at.getTime()) and rebuild the user's wall clock with .toZonedDateTimeISO(row.time_zone).
Precision differs at every hop: Temporal.Instant keeps nanoseconds, Postgres timestamptz keeps microseconds, and JS Date keeps milliseconds. A value that travels from Temporal to Date to Postgres and back is not always the value you started with, so round explicitly with instant.round({ smallestUnit: 'microsecond' }) when exact round-trips matter to a test.
For anything scheduled in the future, such as a weekly reminder at 9am local time, store a PlainDateTime plus the IANA zone rather than an instant. A stored instant freezes today's UTC offset, so the reminder shifts by an hour the next time that zone's DST rules change, and those rules change with only weeks of notice.
Should a team 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 saying Temporal wins.
date-fns is replaceable for arithmetic and comparison, but its tree-shaken functions all operate on Date, so mixed code paths need adapters for the length of the migration.
Day.js and Moment are replaceable outright, because both wrap Date and inherit its time-zone model, and Day.js needs its timezone plugin to do what Temporal.ZonedDateTime does natively.
Luxon is the closest in spirit, since Luxon and the Temporal proposal share an author and an immutable, zone-aware model, which makes that migration mostly a rename.
Intl.DateTimeFormat should be kept. Temporal deliberately does not format for humans and hands off to Intl instead.
The migration order that works is 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 application stop touching Date entirely. A Zod schema is a good home for that parse, using z.string().transform((s) => Temporal.PlainDate.from(s)) to get a validated Temporal value with a real error message.
Temporal is a TC39 Stage 3 proposal, and browsers have begun enabling it, with Firefox shipping it unflagged first and Safari following, while Chromium and Node.js are still catching up. Because coverage is uneven, install temporal-polyfill, the compact implementation, or @js-temporal/polyfill, the reference implementation, import it from one shared module rather than per feature, and check the cost in a bundle analyzer. When native support covers enough of your traffic, deleting that single import is the entire second half of the migration.
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 conversion runs through Temporal.Instant.fromEpochMilliseconds(date.getTime()) and new Date(instant.epochMilliseconds).
Q: Why can two Temporal values not be compared 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 becomes a decision instead of an accident.
Q: Can Temporal be used in Node.js and inside Server Components?
A: Yes, through a polyfill. Import temporal-polyfill in server code exactly as in the browser, because Temporal works inside Server Components, Route Handlers, and Server Actions, and only the props boundary needs strings.
Q: Should an instant or a wall-clock time be stored 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.
Further Reading
Frontend Engineering
Parallel Routes and Intercepting Routes in the Next.js App Router: Field Notes on the Photo-Modal Pattern
We use parallel routes and intercepting routes to build shareable photo modals in the Next.js App Router — named @slot folders, the (.) interception convention, and the default.tsx file that decides what happens on a hard refresh.
Frontend Engineering
next/image in Next.js 16: What Is Not Automatic About Image Optimization
Using next/image is not the same as having fast images. The component lazy-loads every image it renders unless you pass priority, and a wrong sizes prop makes a 400-pixel card download the 3840-pixel candidate. We collected the parts our team has to configure by hand.