# React Appointment Booking That Gets Time Zones Right

> Build React appointment booking in three steps: date, time slot, details. Host hours in their time zone, no double bookings, and when a hosted scheduler wins.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/react-appointment-booking

## TL;DR

React appointment booking works best as three steps: pick a day, pick a time slot, then enter details. Store the host's hours in the host's IANA time zone, work out the slots for each visitor's zone, drop taken slots and anything inside the minimum notice, and check the booking again on the server so two people cannot get the same time. Wingo UI's Demo Booking Page (a Pro template) does the whole flow, and Calendar, Time Picker and Timezone Select cover the parts when you build your own.

React appointment booking sounds like a calendar plus a few buttons, and most tutorials stop there. Then a visitor in Berlin books your New York host at 3 AM her time, someone grabs a slot that starts in ten minutes, two people confirm the same 2 PM, and on a phone the time grid sits under a sticky header nobody can reach. This guide builds the flow in three steps (date, time slot, details) with Wingo UI: host hours kept in the host's time zone, taken slots, minimum notice, a server check, and a phone layout. It ends with the cases where embedding a hosted scheduler is the better call.

## What does a good appointment booking flow need?

Ask for a react appointment booking calendar or a shadcn booking calendar and the usual answer is a month grid beside a column of time buttons. shadcn/ui's own [calendar-20 block](https://ui.shadcn.com/r/styles/new-york/calendar-20.json) (checked November 6, 2026) is exactly that: fixed times every 15 minutes from 9:00 to 18:00 and a hardcoded list of booked days. That is the right shape. What it leaves out is everything that decides whether a booking is correct:

1. **One question per step.** Date, then time, then name and email. On a phone each step fits one screen, and the visitor never scrolls past a form to find the times.
2. **Days without times are disabled.** A weekend, a day past the booking window, or a day where every slot is gone should not be clickable. Opening on the first day with an open time saves a tap.
3. **Times in the visitor's zone, hours in the host's.** The host works 9 to 5 in New York. A visitor in Berlin should see afternoon and evening slots, and a visitor in Tokyo may see them spill onto the next calendar day.
4. **Minimum notice.** No slot starts sooner than, say, four hours from now.
5. **A server check.** The UI hides taken slots, the database refuses the second booking.
6. **A confirmation people can use.** The day, time range and zone, a meeting link, add to calendar, and a way to reschedule.

## How do I get the whole booking flow in one component?

The [Demo Booking Page](https://wingo-ui.com/templates/demo-booking-page) template is all six points in one page. The booking card walks through the date, time and details steps with a stepper, lists times in the visitor's zone (detected from the browser and changeable), validates each step in place, and ends on a confirmation with Google Calendar, Outlook and `.ics` buttons and a Reschedule action that keeps the details.

> Live demo (Demo Booking Page): Switch the time zone to Berlin, pick a day and continue: the same New York hours come back as afternoon and evening slots. Continue without a name to see the errors in place. Try it at [Demo Booking Page](https://wingo-ui.com/templates/demo-booking-page) and install it with `npx wingo-ui@latest add demo-booking-page`.

Install it, then pass your hours and a submit handler:

```bash
npx wingo-ui@latest add demo-booking-page
```

```tsx
// app/(marketing)/book/page.tsx
"use client"

import Link from "next/link"
import { DemoBookingPage } from "@/components/templates/demo-booking-page"

export default function BookRoute() {
  return (
    <DemoBookingPage
      linkComponent={Link}
      // the host's week, in the host's zone: Monday to Thursday, 10 AM to 4 PM
      hours={{ timeZone: "America/Chicago", days: [1, 2, 3, 4], start: 10, end: 16 }}
      duration={45}
      interval={15}
      leadHours={12}
      daysAhead={28}
      meetingUrl="https://meet.example.com/emma-carter"
      // shown when onSubmit rejects, e.g. after a 409 from the API
      labels={{ failed: "Someone just booked that time. Go back and pick another one." }}
      onSubmit={async (values) => {
        const response = await fetch("/api/bookings", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            ...values,
            start: values.start.toISOString(),
            end: values.end.toISOString(),
          }),
        })
        if (!response.ok) throw new Error("Slot taken")
      }}
    />
  )
}
```

The props that matter for booking:

| Prop | Default | What it does |
|---|---|---|
| `hours` | Monday to Friday, 9 to 17, New York | `{ timeZone, days, start, end }`: the host's week, with `days` as 0 (Sunday) to 6 and whole hours |
| `duration` | `30` | Call length in minutes; also the end time and the calendar event |
| `interval` | `30` | Minutes between start times |
| `leadHours` | `4` | Minimum notice: no slot starts sooner than this |
| `daysAhead` | `42` | How far ahead days can be booked |
| `defaultTimeZone` | `""` | The visitor's zone on load; empty uses the browser's |
| `hourCycle` | `"auto"` | `"12h"`, `"24h"` or the locale's choice |
| `onSubmit` | waits 900 ms | Gets `{ start, end, timeZone, name, email, companySize, notes, rescheduled }` with `start` and `end` as `Date`; a rejected promise shows `labels.failed` in the details step |

One caveat: the template marks some slots as taken with a stable pseudo random pick so the sample looks real. It is copied into your project as source, so for production you replace that `taken` check with your real busy times (the next section shows the shape). The template is Pro, and so are the Calendar, Time Picker and Timezone Select below; the [Pro plan](https://wingo-ui.com/pricing) is $8 a month, $80 a year or $150 once.

## How do I build a time slot picker in React myself?

If the booking lives inside your app, say a clinic dashboard or a coaching tool, you may want only the parts. The [Calendar](https://wingo-ui.com/components/calendar) picks the day, a list of buttons is the time slot picker, and the [Timezone Select](https://wingo-ui.com/components/timezone-select) sets the zone. The template exports its slot math as `getDemoBookingSlots(day, options)`, so you do not have to write the zone conversion yourself:

```tsx
"use client"

import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import { TimeZoneSelect } from "@/components/ui/timezone-select"
import { getDemoBookingSlots } from "@/components/templates/demo-booking-page"
import type { DemoBookingHours } from "@/components/templates/demo-booking-page-data"

const HOURS: DemoBookingHours = { timeZone: "America/New_York", days: [1, 2, 3, 4, 5], start: 9, end: 17 }
const DURATION = 30

type Busy = { start: number; end: number } // epoch ms, from your API

const isoDay = (date: Date) =>
  `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`

// open slots per day, cached: the calendar asks for every visible day on each render
function slotLookup(zone: string, busy: Busy[], notBefore: number) {
  const cache = new Map<string, Date[]>()
  return (date: Date) => {
    const key = isoDay(date)
    let slots = cache.get(key)
    if (!slots) {
      slots = getDemoBookingSlots(key, {
        hours: HOURS,
        timeZone: zone,
        duration: DURATION,
        interval: 30,
        notBefore,
        hideTaken: false,
      }).filter((slot) => {
        const start = slot.getTime()
        const end = start + DURATION * 60_000
        return !busy.some((b) => start < b.end && end > b.start)
      })
      cache.set(key, slots)
    }
    return slots
  }
}

export function SlotPicker({ busy, onPick }: { busy: Busy[]; onPick: (start: Date) => void }) {
  const [zone, setZone] = useState("America/New_York")
  const [day, setDay] = useState<Date | null>(null)
  // "now", fixed when the picker mounts so renders stay pure
  const [now] = useState(() => Date.now())
  const notBefore = now + 4 * 3_600_000 // four hours of notice
  const slotsFor = useMemo(() => slotLookup(zone, busy, notBefore), [zone, busy, notBefore])

  const format = new Intl.DateTimeFormat("en-US", { timeZone: zone, hour: "numeric", minute: "2-digit" })

  return (
    <div className="grid gap-6 md:grid-cols-[auto_1fr]">
      <Calendar
        value={day}
        onValueChange={setDay}
        min={new Date(now)}
        disabled={(date) => slotsFor(date).length === 0}
      />
      <div className="grid content-start gap-4">
        <TimeZoneSelect label="Time zone" value={zone} onValueChange={setZone} date={day ?? undefined} />
        {day && (
          <ul className="grid grid-cols-2 gap-2 sm:grid-cols-3" aria-label="Open times">
            {slotsFor(day).map((slot) => (
              <li key={slot.getTime()}>
                <Button variant="outline" className="w-full" onClick={() => onPick(slot)}>
                  {format.format(slot)}
                </Button>
              </li>
            ))}
          </ul>
        )}
      </div>
    </div>
  )
}
```

That is a complete React time slot picker in under 80 lines. Four details in it do most of the work. `disabled` takes a function, so a day is off exactly when it has no open slot, and disabled days stay focusable so the keyboard can pass over them. `hideTaken: false` turns off the template's sample busy times so only your `busy` list counts. The cached `slotLookup` matters because the Calendar runs `disabled` for every visible day on each render; pass a stable `busy` array (from state or your data hook) or the cache resets every time. And `date` on the Timezone Select makes the offsets in the list follow daylight saving time for the picked day, not today.

> Live demo (Timezone Select): Type EST, tokyo or +5:30 into the field: each finds its zone, and the offsets follow daylight saving time. Try it at [Timezone Select](https://wingo-ui.com/components/timezone-select) and install it with `npx wingo-ui@latest add timezone-select`.

## Why keep host hours in the host's time zone?

Because offsets move, and they do not move on the same day everywhere. In 2026 Europe left summer time on October 25 and the US on November 1, so for that week Berlin was 5 hours ahead of New York instead of 6. A host who works 9 to 5 in New York was 13:00 to 21:00 UTC in October and is 14:00 to 22:00 UTC since November 1. Store the October UTC range and every slot starts an hour early on the host's own clock from November on. Store `{ timeZone: "America/New_York", start: 9, end: 17 }`, compute instants for each day, and send `start.toISOString()` to the server. The visitor's zone is only for display.

## When does the Time Picker fit instead of slot buttons?

A React calendar with time slots shows fixed starts, which is right for public booking. The [Time Picker](https://wingo-ui.com/components/time-picker) fits the other side: the host setting their hours, or a request form where any time inside a window is fine. `min` and `max` bound the window, `minuteStep` sets the grid, and `disabledTimes` blocks single times such as a lunch break or booked slots:

```tsx
import { TimePicker } from "@/components/ui/time-picker"

<TimePicker
  label="Preferred time"
  min="09:00"
  max="17:00"
  minuteStep={15}
  disabledTimes={(time) => time >= "12:00" && time < "13:00"}
/>
```

On a phone it opens a bottom sheet with snapping wheels instead of raising the keyboard. If you need a date and a time in one field, see our [shadcn date time picker guide](https://wingo-ui.com/blog/shadcn-date-time-picker) and the [React time picker guide](https://wingo-ui.com/blog/react-time-picker).

## How do I stop double bookings on the server?

Hiding taken slots is a hint. Two visitors can load the page at 9:00 and both confirm 2 PM. Let the database decide. In Postgres an exclusion constraint refuses overlapping ranges for the same host:

```sql
create extension if not exists btree_gist;

create table bookings (
  id uuid primary key default gen_random_uuid(),
  host_id uuid not null,
  slot tstzrange not null,
  email text not null,
  exclude using gist (host_id with =, slot with &&)
);
```

```ts
// app/api/bookings/route.ts
import { Pool } from "pg"

const pool = new Pool()

export async function POST(request: Request) {
  const { start, end, email } = await request.json()
  // re-check here too: inside the host's hours, past the minimum notice, inside the booking window,
  // and derive end from start plus your duration instead of trusting the client
  try {
    await pool.query(
      "insert into bookings (host_id, slot, email) values ($1, tstzrange($2, $3, '[)'), $4)",
      [process.env.HOST_ID, start, end, email],
    )
    return Response.json({ ok: true })
  } catch (error) {
    if ((error as { code?: string }).code === "23P01") {
      return Response.json({ error: "slot_taken" }, { status: 409 })
    }
    throw error
  }
}
```

`23P01` is Postgres's exclusion violation. The `'[)'` bounds let one booking end at 2:30 and the next start at 2:30. When the route answers 409, the `onSubmit` above throws, the template shows the `labels.failed` text, and the visitor goes back a step to pick another time. Our [resource scheduler guide](https://wingo-ui.com/blog/react-resource-scheduler) uses the same constraint for rooms and cars.

## What should booking look like on a phone?

The steps help most here. In the Demo Booking Page the stepper becomes a compact "Step 1 of 3" header, Back and Continue sit in a bar pinned to the bottom of the screen, and the time zone list opens as a bottom sheet. The calendar cells grow to 44px on touch and a swipe changes the month. Keep the slot list to two or three columns of full-width buttons; a seven-column week of times does not fit 390px.

## When should I embed a hosted scheduler instead?

Building it is worth it when the booking is your product data. If you mainly need a "book a call" link, a hosted scheduler does more on day one: it reads busy times from Google or Outlook calendars, sends confirmations, and handles reschedule and cancel links. Team scheduling sits on the paid plans. Their public pricing pages, checked November 6, 2026:

| Option | Free plan | Paid plans | Source |
|---|---|---|---|
| Cal.com | Free forever, 1 user, unlimited calendars | Teams $12 per user per month, Organizations $28 per user per month | [cal.com/pricing](https://cal.com/pricing) |
| Calendly | Always free, 1 event type, 1 calendar connection | Standard $10 and Teams $16 per seat per month, billed yearly | [calendly.com/pricing](https://calendly.com/pricing) |

Embed one when calendar sync matters more than design and you are fine with the booking living in their system. Build your own when you need your own fields, your own look in light and dark, bookings next to your users and payments in your database, or no per-seat fee as the team grows. Starting with an embed and moving once booking becomes a core feature is a reasonable path too.

For more screens built end to end, see the [Build it category](https://wingo-ui.com/blog/category/build) and the [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial).

## Where do I start?

Open the [Demo Booking Page template](https://wingo-ui.com/templates/demo-booking-page), switch the time zone and walk the three steps on your phone. If it fits, sign in with your Pro account and `npx wingo-ui@latest add demo-booking-page` copies it into your project with the Calendar, the stepper and the fields it uses, ready for your hours and your `/api/bookings` route.

## Components in this post

- [Demo Booking Page](https://wingo-ui.com/templates/demo-booking-page) (Pro): A React book-a-demo page template with the agenda, host and logos beside a three step booking. Install: `npx wingo-ui@latest add demo-booking-page`
- [Calendar](https://wingo-ui.com/components/calendar) (Pro): A React calendar to pick one day, several days or a range, with disabled days, event dots, several months and a quick month / year jump. Install: `npx wingo-ui@latest add calendar`
- [Time Picker](https://wingo-ui.com/components/time-picker) (Pro): A React time picker to type a time or pick it from columns, with 12 / 24 hours by locale, minute steps, limits and wheels on phones. Install: `npx wingo-ui@latest add time-picker`
- [Timezone Select](https://wingo-ui.com/components/timezone-select) (Pro): A searchable time zone picker with live UTC offsets, optional local times, region groups and a one-tap Use my time zone row. Install: `npx wingo-ui@latest add timezone-select`

## FAQ

### How do I build an appointment booking page in React?

Split it into three steps: a calendar that only enables days with open times, a list of time slots for the picked day, and a short details form. Generate the slots from the host's weekly hours, remove taken ones and those inside the minimum notice, then confirm the booking on the server before you show the success screen.

### How should a booking calendar handle time zones?

Save the host's hours as wall clock times in an IANA zone such as America/New_York, never as a fixed UTC offset. Convert each slot to the visitor's zone when you render it and send the booking to your server as an ISO timestamp, so daylight saving changes cannot shift it.

### How do I stop two people booking the same time slot?

Hide taken slots in the UI, but treat that as a hint, because two visitors can confirm at the same second. In Postgres an exclusion constraint on a tstzrange column refuses overlapping bookings for the same host, and your API answers the losing request with a 409.

### Should I embed Calendly or Cal.com instead of building booking myself?

Embed one when you need calendar sync with Google or Outlook, reminders, reschedule links and team routing soon and the booking does not need to live in your own database. Build it yourself when the booking is part of your product data, needs your own fields or design, or you do not want a per-seat fee.

### Is there a shadcn booking calendar with time slots?

Yes, shadcn/ui's calendar-20 block pairs its Calendar with a column of fixed 15 minute times and a few booked days, but it has no time zones, minimum notice or details step. Wingo UI installs the same way, as source code, and its Demo Booking Page adds the time zone math, taken slots, minimum notice, validation and a phone layout.

---

Source: https://wingo-ui.com/blog/react-appointment-booking
