Booking looks simple and isn’t. The UI is a calendar and a list of slots. Underneath is the hardest ordinary problem in application development.
Every naive implementation works perfectly in one timezone in the middle of summer, and breaks the first time someone abroad books a meeting in late October.
The Rules That Prevent Most Bugs
Establish these before generating anything:
- Store instants in UTC. Every actual booking time, in UTC, always.
- Store timezone names, not offsets.
"America/New_York", never"-05:00"— offsets change twice a year. - Convert only at the edges. Display and input convert; nothing in between.
- Availability rules are local; bookings are absolute. “I’m free 9–5 Tuesdays” is a rule in the host’s local time. A booking is a fixed instant.
That fourth point is the subtle one. If you store availability in UTC, a host who’s free 9am–5pm local suddenly appears free 8am–4pm after the clocks change.
The Build
Next.js App Router, TypeScript, Tailwind, Supabase. A booking app where a host publishes availability and guests book slots.
Tables:
hosts: id, user_id, timezone text (IANA name), slot_duration_minutes, buffer_minutesavailability_rules: id, host_id, day_of_week 0-6, start_time time, end_time time — all in the host’s local timebookings: id, host_id, guest_email, guest_timezone, starts_at timestamptz, ends_at timestamptz, statusAll timestamptz columns store UTC. Use date-fns-tz for conversions. Never use raw Date arithmetic for timezone maths.
That last sentence prevents the single most common bug: new Date(x).setHours(9) operates in the server’s timezone, which is usually UTC in production and your local timezone in development. The code works on your machine and breaks when deployed.
Generating Slots
Write a server function that generates available slots for a date range:
- Read the host’s availability rules (local time) and timezone
- For each date in range, construct that day’s window in the host’s timezone using zonedTimeToUtc — so DST is applied for that specific date
- Divide into slots of slot_duration plus buffer
- Remove slots overlapping existing bookings
- Remove slots in the past relative to now
- Return UTC instants
Convert to the guest’s timezone only at render time.
Step 2 is where correctness lives. Converting per-date means each date gets the offset that actually applied on that date.
Double Booking
The obvious implementation has a race condition:
const conflicts = await checkOverlap(start, end) // ← two requests both pass here
if (conflicts.length === 0) await createBooking(start, end)
Two people booking the same slot within milliseconds both see it free. Both bookings are created.
Fix it in the database:
Prevent double bookings with a Postgres exclusion constraint using btree_gist, so overlapping time ranges for the same host are rejected atomically. Handle the constraint violation in the application and return a clear “slot no longer available” message. Do not rely on an application-level availability check.
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (
host_id WITH =,
tstzrange(starts_at, ends_at) WITH &&
) WHERE (status <> 'cancelled');
The database now guarantees it regardless of concurrency.
Display
Detect the guest’s timezone with Intl.DateTimeFormat().resolvedOptions().timeZone and let them override it with a searchable selector. Show the selected timezone prominently near the slot list. Display each slot in the guest’s timezone with the host’s local time underneath in smaller text.
Showing both times prevents the most common support message in every booking product.
Emails and Calendar Invites
On booking, email both parties. Each email must show the time in that recipient’s own timezone with the timezone name written out. Attach an .ics file with the event in UTC plus a TZID, so calendar clients localise correctly.
Send reminders 24 hours and 1 hour before, scheduled against the UTC instant.
Cancellation and Rescheduling
Allow cancellation via a signed token link, no login required. Cancelling frees the slot immediately. Rescheduling must be atomic — book the new slot before releasing the old one, so a failure doesn’t lose both.
Signed tokens matter: a cancellation link with a guessable booking ID lets anyone cancel anyone’s meeting.
Test the Cases That Break
Do these deliberately. They won’t surface on their own.
- Guest in a different timezone from the host — times correct on both sides?
- Guest in a half-hour offset zone (India, +5:30)
- Booking across the March DST transition
- Booking across the October DST transition
- A host in a country with no DST, guest in one with it
- Two simultaneous bookings for the same slot — one must fail
- Booking near midnight in the guest’s timezone (date boundary)
- Server timezone set to something unusual — does anything change?
That last one catches every place raw Date methods snuck in. Set TZ=Pacific/Kiritimati and run through the flow.
The Checklist
- All instants in UTC
- IANA timezone names stored, never offsets
- Availability rules in host local time, converted per date
- date-fns-tz used throughout, no raw Date arithmetic
- Exclusion constraint prevents overlaps at the database level
- Guest timezone detected and overridable
- Both timezones shown on slots and emails
- .ics uses UTC with TZID
- Cancellation links signed
- Rescheduling atomic
- All eight test cases pass
Why This One Is Worth Building
Booking is the project where you learn that “it works” and “it’s correct” are different claims. Everything can look right and be wrong, and you won’t find out until October.
That’s a useful lesson to internalise cheaply, on a project where the consequence is a confused meeting rather than a lost payment.
Related reading: