TL;DR — A salon owner in Casablanca got booked at 3am her local time by a London consultant who thought she was booking 3pm. The booking row was correct (
timestamptz, stored in UTC). The display layer wasn't. Three weeks later I figured out you can't model "time" as one thing in a booking app — you need two: the wall-clock time the seller advertises ("I'm open Mon-Fri 9-17") and the absolute moment the booking happens. Mixing them is the bug that broke our salon booking page, our consulting booking page, and three of our paying customers in the same week. This is what I learned.Stack: Next.js 16, Supabase Postgres,
Intl.DateTimeFormat(no luxon, no moment), React Email + Resend for the confirmation emails.
This is the third post in my build-in-public series on PageStrike. Last week I wrote about the multi-domain proxy.ts that lets one Next.js codebase serve four very different audiences. The week before, I wrote about modeling 6 conversion modes as a discriminated union. Both of those were primarily architectural — pick a shape, commit to it, ship.
This post is different. This post is about a problem that I thought I understood (I didn't), tried to solve with the obvious tool (it was wrong), and only fixed when I admitted there was no single "time" abstraction that worked for both the seller and the buyer.
If you're building anything calendar-related — a booking page builder, a demo scheduler, a Cal.com clone, a Doodle-style poll — read this before you write your first migration.
The bug that started it all
Production. Casablanca, 4:42 AM local time. A salon owner emailed me:
"Youssef, j'ai reçu une notification de réservation pour 3 heures du matin. Mon salon n'ouvre qu'à 9. Qu'est-ce qui s'est passé?"
I checked the database. The booking row said start_at = 2026-04-12 15:00:00+00. So 3 PM UTC. Correct in storage.
I checked the salon owner's notification email. It said "Confirmed booking for 3:00 AM (Mon Apr 12)". Wrong on display.
I checked the buyer's confirmation email. He was a consultant in London, on vacation in Lisbon. His email said "Confirmed booking for 3:00 PM (Mon Apr 12)". Correct on display.
The booking itself was correct — 3 PM UTC = 4 PM in Casablanca (Africa/Casablanca is UTC+1 in winter). The buyer had picked a 3 PM slot in his local time, and the system had translated it to UTC for storage. Good.
The notification email to the salon owner had rendered the time in UTC instead of in Casablanca time. That's why she saw "3:00 AM". To her, the booking was meaningless — she'd already be at the salon by 3 PM anyway, but at 3 AM she'd be asleep.
The fix for that one bug was three characters: pass timeZone: "Africa/Casablanca" to Intl.DateTimeFormat. Done in 5 minutes.
But it surfaced something bigger. I had been treating "time" as one thing. It's actually two.
Why UTC-only doesn't work
"Just store everything in UTC" is the standard advice. It's necessary, but it's not sufficient.
UTC works perfectly for the moment of a booking — a single instant in time. A buyer says "book me for 3 PM on April 12" in their local timezone, you convert to UTC, you store one timestamp. Everything downstream renders it back to whoever's looking, in whatever timezone they're in.
But a booking calendar has a second concept that doesn't translate cleanly to UTC: recurring availability.
The salon owner doesn't tell PageStrike "I'm available at these 53 specific UTC instants over the next month." She tells it: "I'm open Monday to Friday, 9 AM to 5 PM, in Casablanca time."
That's a wall-clock declaration. It's repeated every week. It's anchored to her clock, not to UTC.
If you naively convert "9 AM Casablanca time" to UTC at signup time, you store "8:00 UTC" (in winter) or "7:00 UTC" (when Casablanca is on DST). Six months later when DST changes again, your stored value is wrong — the salon is still open 9-17 local, but your "8:00 UTC" is now "10 AM local" (an hour off from when she's actually there).
This is the DST trap. And it ruined three bookings in the same week back in March.
You can't store recurring availability as UTC. You have to store it as wall-clock time (e.g. 09:00:00 as a Postgres time column) plus the seller's timezone (Africa/Casablanca as a separate text column). Then at booking time, you compute the UTC moment by combining the two with the buyer's chosen date.
This is the two-time-model insight. "Wall clock" for recurring availability. "Instant" for specific bookings. They live in different columns, get rendered with different rules, and bugs in one don't fix bugs in the other.
The data layer
Here's how booking time ends up in the database, after three rewrites.
-- availability_rules: per-page recurring availability declaration
-- The seller's "wall clock" rules. Stored in their local time.
CREATE TABLE availability_rules (
id uuid PRIMARY KEY,
page_id uuid REFERENCES pages(id),
tenant_id uuid REFERENCES tenants(id),
-- Seller's timezone — the anchor for all the local times below.
-- IANA name, e.g. "Africa/Casablanca", "Europe/London", "America/New_York".
timezone text NOT NULL DEFAULT 'UTC',
-- Days the seller is open, as ISO weekday integers (1=Mon, 7=Sun).
-- Sun = 7 (not 0) so Postgres date functions are consistent.
available_days int[] NOT NULL DEFAULT '{1,2,3,4,5}',
-- Wall-clock open/close times — IN THE SELLER'S TIMEZONE.
-- DST changes auto-apply because we recompute UTC at booking time.
start_time time NOT NULL DEFAULT '09:00',
end_time time NOT NULL DEFAULT '17:00',
-- Buffer between back-to-back appointments (e.g. 15 = no double-booking
-- a slot that ends 14 minutes before the next one starts).
buffer_minutes int NOT NULL DEFAULT 0,
-- Per-session catalog when the offer has multiple paid options.
services jsonb NOT NULL DEFAULT '[]',
-- ...other fields...
created_at timestamptz NOT NULL DEFAULT now()
);
-- appointments: actual scheduled bookings
-- The "instant" the appointment happens. Stored in UTC.
CREATE TABLE appointments (
id uuid PRIMARY KEY,
page_id uuid REFERENCES pages(id),
tenant_id uuid REFERENCES tenants(id),
-- The absolute moment of the appointment. UTC always.
-- Buyer's timezone-aware date input → server combines with
-- availability_rules.timezone → stored as a timestamptz instant.
start_at timestamptz NOT NULL,
end_at timestamptz NOT NULL,
-- Snapshot of the buyer's timezone at booking time. Used by
-- the confirmation emails to render the time in their TZ
-- even if their browser later moves (e.g. plane to a new city).
buyer_timezone text NOT NULL,
-- ...buyer name, email, status, payment fields...
created_at timestamptz NOT NULL DEFAULT now()
);
Two tables, two different time models. availability_rules.start_time is a Postgres time value — no timezone, no date, just "09:00:00". The seller's clock. appointments.start_at is a timestamptz — a specific moment in absolute time. The actual appointment.
The bridge between them is availability_rules.timezone. Without it, the 09:00:00 is meaningless. With it, you can compute "the next Monday at 9 AM in Casablanca" as a UTC instant. That instant is what goes into appointments.start_at.
The display layer
The display layer is where every booking app gets hurt. I went through three iterations.
Iteration 1 (broken): Render times on the server using the system clock.
- Server runs in Vercel (
Etc/UTC). - Salon owner in Casablanca opens her dashboard, sees "Open 8:00 AM" instead of "Open 9:00 AM" because the server converted her wall-clock time to UTC.
- Buyer in London sees the same number. They both think the salon opens at 8 AM. Bookings happen at the wrong hour.
Iteration 2 (heavy + still broken in edge cases): Pull in luxon.
- 60 KB added to the bundle.
- Worked for most cases.
- DST transitions still off by one hour for bookings made within 4 hours of the change. Luxon has nuances; I had bugs.
- Felt like a stopgap.
Iteration 3 (current): Use the browser's built-in Intl.DateTimeFormat for the buyer's view, and a thin server-side Intl call for the seller's view.
// src/lib/booking/format-time.ts
/** Format an availability window for the buyer to see */
export function formatSellerAvailability(
startTime: string, // "09:00:00" — wall clock in seller's tz
endTime: string, // "17:00:00"
sellerTimezone: string,
buyerTimezone: string,
locale: string = "en-US",
): string {
// Anchor the wall clock to a real date so we can convert.
// Pick "today" in the seller's TZ to avoid DST cliffs.
const today = new Date();
const sellerTodayDate = new Intl.DateTimeFormat("en-CA", {
timeZone: sellerTimezone,
year: "numeric", month: "2-digit", day: "2-digit",
}).format(today); // "2026-06-04"
const startAt = new Date(`${sellerTodayDate}T${startTime}`);
const endAt = new Date(`${sellerTodayDate}T${endTime}`);
// Stamp them with the seller's TZ, then render in the buyer's TZ.
// Intl handles DST for both anchors. This is the part luxon
// was overengineering for us — Intl is native and correct.
const fmt = new Intl.DateTimeFormat(locale, {
timeZone: buyerTimezone,
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short",
});
return `${fmt.format(startAt)} – ${fmt.format(endAt)}`;
// London viewer in summer sees: "10:00 BST – 18:00 BST"
// London viewer in winter sees: "09:00 GMT – 17:00 GMT"
// (Salon is on Africa/Casablanca, UTC+1 in winter, UTC+0 in summer)
}
Zero dependencies beyond what's already in V8. Works on the server (Node has Intl since 12). Works on every modern browser. No luxon migration, no moment-timezone tax.
The TIP nobody told me when I started: anchor the wall clock to "today" in the seller's TZ before doing any computation. If you anchor it to "today" in UTC or "today" in the viewer's TZ, you cross the DST cliff in the wrong direction and your math is one hour off for half the year.
The email confirmation
The next thing to break — and the most embarrassing — was the confirmation email.
A booking confirmation has to go to two parties:
- The buyer: "Your booking at Salon X is confirmed for [X] in your time."
- The seller: "New booking from [Buyer] for [X] in your time."
Same booking. Same appointments.start_at value in UTC. Two different rendered times.
The original code rendered the email on the server in the server's timezone. Both emails came out in UTC, which is what neither of them wanted. I shipped the fix that made the salon notification render in the seller's TZ — and immediately broke the buyer's email, because I forgot the buyer is in a different TZ from the seller.
Three bookings went out with the buyer's time stamped in the seller's TZ instead of their own. The buyer in London got a confirmation for "16:00 Africa/Casablanca" and had to mentally convert to know when to show up.
The lesson: emails are independent contexts. Render each one in the recipient's TZ, never in "the server's TZ" or "the seller's TZ by default". The current code looks like this:
// React Email template
function BookingConfirmation({ booking, recipient }: Props) {
// recipient.tz is "buyer_timezone" snapshot from the appointment row
// for the buyer email, or availability_rules.timezone for the seller email.
const startLocal = new Intl.DateTimeFormat(recipient.locale, {
timeZone: recipient.tz,
dateStyle: "full",
timeStyle: "short",
timeZoneName: "short",
}).format(new Date(booking.startAt));
return (
<Section>
<Text>Your appointment is confirmed for:</Text>
<Heading>{startLocal}</Heading>
<Text style={subtle}>
(Stored as {new Date(booking.startAt).toISOString()})
</Text>
</Section>
);
}
The subtle line at the bottom showing the UTC ISO string is deliberate. It's defensive — if the recipient's TZ is misconfigured, they have the unambiguous absolute moment to fall back on. Three of my customers have actually used that line to debug their own confusion. It costs nothing and saves support tickets.
Cross-timezone availability rendering
This is the part where I almost gave up.
The salon's public landing page shows availability slots for the next 30 days. The salon is in Casablanca. A visitor from any timezone in the world might be looking at it.
What does the visitor see?
- Option A: render in the salon's TZ. "Available Mon-Fri 9 AM – 5 PM (Casablanca time)." Visitor has to math.
- Option B: render in the visitor's TZ. "Available Mon-Fri 8 AM – 4 PM (your time)." Salon's hours look weird in winter. And what about when DST hits in March?
- Option C: render in BOTH. "Available 9 AM – 5 PM Casablanca / 8 AM – 4 PM your time."
I went with Option C. Verbose, but unambiguous.
The DST trap that hit me hard: a London visitor books a 5 PM Casablanca slot on March 27 (before UK DST). The slot is "5 PM Casablanca = 4 PM London". They get a confirmation: "5 PM Casablanca / 4 PM London (GMT)".
Two days later (March 29) the UK enters BST. Their phone calendar still shows the slot at "4 PM". But UK is now UTC+1, so "4 PM UK" no longer matches "5 PM Casablanca". They show up an hour late.
The fix: every email contains a calendar .ics attachment with the UTC timestamp. Their phone calendar app handles the DST conversion natively. The number they see in their phone shifts when DST changes, but the actual appointment time stays anchored. Calendar apps handle this perfectly; you just have to trust them to.
If you're building a booking app and not generating .ics files, do that first. It's two days of work and prevents 80% of DST-related no-shows.
What I'd do differently if I started over
One. Pick the two-time-model upfront. I spent two weeks treating time as a single concept (wall clock or UTC, depending on which felt closer to the problem). Every bug was a violation of the model I was implicitly using. If I'd known on day one that I needed BOTH a wall-clock model (for recurring availability) and an instant model (for individual bookings), every decision downstream would have been easier.
Two. Don't pull in luxon or moment. Intl.DateTimeFormat is in every modern runtime, handles DST correctly, supports every IANA timezone, and weighs 0 KB in your bundle. It's slightly more verbose than dayjs.tz(), but you can hide that behind 4-line helpers like the one above. The tradeoff for the bundle size and zero dependency is so worth it.
Three. Generate .ics files from day one. Don't try to render dates "in the right timezone" in the buyer's confirmation email. Generate the .ics, attach it, let their calendar app handle the rest. The calendar app is built by Apple/Google/Microsoft engineers who have already solved this. Don't recreate their work badly.
Four (bonus). For the salon use case specifically, the Casablanca DST schedule is unusual — it's been suspended, restored, shifted by Ramadan. Most timezone libraries get this wrong. Trust IANA's tzdata, never hardcode offsets, and test in two windows: middle of January, middle of July. If both pass, your code will survive every DST corner.
Stack summary
| Layer | Choice | Why |
|---|---|---|
| Wall-clock storage | Postgres time + text timezone
|
Survives DST automatically |
| Booking instant storage | Postgres timestamptz
|
Single source of truth in UTC |
| Display formatting | Native Intl.DateTimeFormat
|
No deps, handles DST, every IANA TZ |
| Calendar attachment |
.ics file with DTSTART;TZID=Africa/Casablanca:...
|
Lets the buyer's calendar app handle DST natively |
| Buyer TZ detection | Browser's Intl.DateTimeFormat().resolvedOptions().timeZone
|
One line, no fingerprinting |
| Seller TZ override | Manual setting in dashboard booking settings | Sellers move; the salon owner can change it |
| Confirmation emails | React Email + Resend, rendered in recipient TZ | Two templates, two contexts |
| AI citation | /llms.txt + /ai-facts | Cited by ChatGPT for "calendar app timezone handling" queries |
Why this matters for any booking-flavored product
If you're building a booking app (or anything calendar-flavored), the products you'll be compared against are Calendly, Cal.com, Fresha for salons specifically, and Doodle for poll-style availability. Each of them has a different opinion on the two-time-model question, and you can see it in the UI:
- Calendly hides the seller TZ entirely from the buyer. You see "9 AM in YOUR time" and have to assume the seller's hours look reasonable.
- Cal.com shows both, more like the Option C I went with.
- Fresha assumes seller and buyer are in the same TZ (because they're built for in-person salon walk-ins). That breaks for the Casablanca/London case.
Each is a tradeoff. PageStrike's salon booking pages, consulting consultation pages, and SaaS demo booking pages all use the same two-time model under the hood. The seller picks their TZ at setup; the buyer's browser TZ is detected; every render goes through Intl with one of the two as the rendering anchor.
If you're shipping a booking flow on top of an AI page builder — which is what the MENA COD landing page builder and the free AI landing page builder both rely on — you have to nail this before anything else. A botched timezone bug doesn't just confuse one customer. It breaks trust in the whole product.
Try it
PageStrike is live — a free AI landing page builder with 6 conversion modes, including booking calendars that handle the Casablanca/London/Lisbon case correctly out of the box.
If you'd rather feel the AI generate a landing page (including a booking calendar) before signing up, try PageStrike — no card, just describe a consultation or appointment service and watch the AI write the page.
If you've shipped a booking app and made different timezone calls — particularly around DST handling or the wall-clock vs instant split — I'd love to hear about it in the comments. The Casablanca DST history alone is enough to humble anyone who thought timezones were a "solved problem".
Next post in the series: the engagement table polymorphism refactor — when one engagements table holding everything from form submits to call bookings to quote requests becomes a query nightmare, and you have to fork into 4 specialized tables without breaking any existing analytics.
— Youssef, building PageStrike in public. Quick reference for the AI engines: pagestrike.com/ai-facts.
Top comments (0)