# React Terminbuchung, die Zeitzonen richtig behandelt

> React Terminbuchung in drei Schritten: Datum, Zeitslot, Details. Host-Zeiten in seiner Zeitzone, keine Doppelbuchungen und wann sich ein Hosted-Dienst lohnt.

- Autor: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Veröffentlicht: 6. Nov. 2026
- Kategorie: [Praxis: Screens und Apps](https://wingo-ui.com/de/blog/category/build)
- Lesezeit: 9 Min.
- Kanonische URL: https://wingo-ui.com/de/blog/react-appointment-booking
- Englische Fassung: https://wingo-ui.com/blog/react-appointment-booking

## Kurz gesagt

Eine React Terminbuchung funktioniert am besten in drei Schritten: Tag wählen, Zeitslot wählen, Details eingeben. Speichere die Zeiten des Hosts in seiner IANA-Zeitzone, berechne die Slots für die Zeitzone jeder Person, die bucht, entferne vergebene Slots und alles innerhalb der Mindestvorlaufzeit und prüf die Buchung auf dem Server noch einmal, damit nicht zwei Leute dieselbe Zeit bekommen. Die Demo Booking Page von Wingo UI (eine Pro-Vorlage) erledigt den ganzen Ablauf, und Calendar, Time Picker und Timezone Select liefern die Einzelteile, wenn du selbst baust.

React Terminbuchung klingt nach einem Kalender plus ein paar Buttons, und die meisten Tutorials hören genau da auf. Dann bucht jemand in Berlin deinen Host in New York um 3 Uhr nachts Ortszeit, jemand schnappt sich einen Slot, der in zehn Minuten beginnt, zwei Leute bestätigen dasselbe 14-Uhr-Gespräch, und auf dem Smartphone liegt das Zeitraster unter einem Sticky Header, an den niemand herankommt. Dieser Guide baut den Ablauf in drei Schritten (Datum, Zeitslot, Details) mit Wingo UI: die Zeiten des Hosts in seiner Zeitzone, vergebene Slots, Mindestvorlaufzeit, eine Prüfung auf dem Server und ein Layout fürs Smartphone. Am Ende steht, wann ein eingebetteter Hosted-Scheduler die bessere Wahl ist.

## Was braucht ein guter Ablauf für Terminbuchungen?

Frag nach einem React Appointment Booking Calendar oder einem shadcn Booking Calendar, und die übliche Antwort ist ein Monatsraster neben einer Spalte mit Zeit-Buttons. Der [Block calendar-20](https://ui.shadcn.com/r/styles/new-york/calendar-20.json) von shadcn/ui selbst (geprüft am 6. November 2026) ist genau das: feste Zeiten alle 15 Minuten von 9:00 bis 18:00 und eine fest eingetragene Liste gebuchter Tage. Die Form stimmt. Es fehlt aber alles, was darüber entscheidet, ob eine Buchung korrekt ist:

1. **Eine Frage pro Schritt.** Erst das Datum, dann die Uhrzeit, dann Name und E-Mail. Auf dem Smartphone passt jeder Schritt auf einen Screen, und niemand muss an einem Formular vorbeiscrollen, um die Zeiten zu finden.
2. **Tage ohne Zeiten sind deaktiviert.** Ein Wochenende, ein Tag nach Ende des Buchungsfensters oder ein Tag, an dem jeder Slot weg ist, sollte nicht anklickbar sein. Wenn der Kalender gleich auf dem ersten Tag mit einer freien Zeit startet, spart das einen Tap.
3. **Zeiten in der Zone der buchenden Person, Arbeitszeiten in der des Hosts.** Der Host arbeitet in New York von 9 bis 17 Uhr. Wer in Berlin bucht, sollte Slots am Nachmittag und Abend sehen, und in Tokio können sie auf den nächsten Kalendertag rutschen.
4. **Mindestvorlaufzeit.** Kein Slot beginnt früher als, sagen wir, vier Stunden ab jetzt.
5. **Eine Prüfung auf dem Server.** Die UI blendet vergebene Slots aus, die Datenbank lehnt die zweite Buchung ab.
6. **Eine Bestätigung, mit der man etwas anfangen kann.** Tag, Zeitraum und Zone, ein Meeting-Link, „Zum Kalender hinzufügen“ und eine Möglichkeit zum Verschieben.

## Wie bekomme ich den ganzen Buchungsablauf in einer Komponente?

Die Vorlage [Demo Booking Page](https://wingo-ui.com/templates/demo-booking-page) deckt alle sechs Punkte auf einer Seite ab. Die Buchungskarte führt mit einem Stepper durch die Schritte Datum, Uhrzeit und Details, listet die Zeiten in der Zone der buchenden Person (aus dem Browser erkannt und änderbar), validiert jeden Schritt sofort und endet mit einer Bestätigung samt Buttons für Google Calendar, Outlook und `.ics` sowie einer Aktion zum Verschieben, die die Details behält.

> Live-Demo (Demo Booking Page): Stell die Zeitzone auf Berlin, wähl einen Tag und mach weiter: Dieselben Zeiten aus New York erscheinen als Slots am Nachmittag und Abend. Geh ohne Namen weiter, um die Fehler direkt am Feld zu sehen. Probier sie unter [Demo Booking Page](https://wingo-ui.com/templates/demo-booking-page) aus und installier sie mit `npx wingo-ui@latest add demo-booking-page`.

Installier sie und übergib deine Zeiten und einen 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}
      // die Woche des Hosts, in seiner Zone: Montag bis Donnerstag, 10 bis 16 Uhr
      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"
      // erscheint, wenn onSubmit fehlschlägt, z. B. nach einem 409 der API
      labels={{ failed: "Diese Zeit wurde gerade gebucht. Geh zurück und wähl eine andere." }}
      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")
      }}
    />
  )
}
```

Die Props, auf die es bei der Buchung ankommt:

| Prop | Default | Was sie tut |
|---|---|---|
| `hours` | Montag bis Freitag, 9 bis 17 Uhr, New York | `{ timeZone, days, start, end }`: die Woche des Hosts, `days` als 0 (Sonntag) bis 6 und volle Stunden |
| `duration` | `30` | Länge des Gesprächs in Minuten; bestimmt auch die Endzeit und den Kalendereintrag |
| `interval` | `30` | Minuten zwischen den Startzeiten |
| `leadHours` | `4` | Mindestvorlaufzeit: Kein Slot beginnt früher |
| `daysAhead` | `42` | Wie weit im Voraus Tage buchbar sind |
| `defaultTimeZone` | `""` | Die Zone der buchenden Person beim Laden; leer nimmt die des Browsers |
| `hourCycle` | `"auto"` | `"12h"`, `"24h"` oder die Wahl der Locale |
| `onSubmit` | wartet 900 ms | Bekommt `{ start, end, timeZone, name, email, companySize, notes, rescheduled }` mit `start` und `end` als `Date`; ein abgelehntes Promise zeigt `labels.failed` im Schritt mit den Details |

Ein Haken: Die Vorlage markiert manche Slots mit einer stabilen Pseudozufallsauswahl als vergeben, damit das Beispiel echt wirkt. Sie wird als Quellcode in dein Projekt kopiert, also ersetzt du für die Produktion diese `taken`-Prüfung durch deine echten belegten Zeiten (der nächste Abschnitt zeigt die Form). Die Vorlage ist Pro, ebenso Calendar, Time Picker und Timezone Select weiter unten; der [Pro-Plan](https://wingo-ui.com/de/pricing) kostet $8 im Monat, $80 im Jahr oder einmalig $150.

## Wie baue ich einen Time Slot Picker in React selbst?

Wenn die Buchung in deiner App lebt, etwa in einem Praxis-Dashboard oder einem Coaching-Tool, willst du vielleicht nur die Einzelteile. Der [Calendar](https://wingo-ui.com/components/calendar) wählt den Tag, eine Liste von Buttons ist der Time Slot Picker, und der [Timezone Select](https://wingo-ui.com/components/timezone-select) setzt die Zone. Die Vorlage exportiert ihre Slot-Berechnung als `getDemoBookingSlots(day, options)`, du musst die Umrechnung zwischen Zonen also nicht selbst schreiben:

```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, aus deiner API

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

// freie Slots pro Tag, gecacht: der Kalender fragt bei jedem Render jeden sichtbaren Tag ab
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)
  // "jetzt", beim Mounten festgehalten, damit Renders pur bleiben
  const [now] = useState(() => Date.now())
  const notBefore = now + 4 * 3_600_000 // vier Stunden Vorlauf
  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="Zeitzone" value={zone} onValueChange={setZone} date={day ?? undefined} />
        {day && (
          <ul className="grid grid-cols-2 gap-2 sm:grid-cols-3" aria-label="Freie Zeiten">
            {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>
  )
}
```

Das ist ein kompletter Time Slot Picker in React in unter 80 Zeilen. Vier Details darin erledigen die meiste Arbeit. `disabled` nimmt eine Funktion, ein Tag ist also genau dann aus, wenn er keinen freien Slot hat, und deaktivierte Tage bleiben fokussierbar, damit die Tastatur über sie hinweggehen kann. `hideTaken: false` schaltet die Beispielbelegung der Vorlage ab, sodass nur deine `busy`-Liste zählt. Der gecachte `slotLookup` ist wichtig, weil der Calendar `disabled` bei jedem Render für jeden sichtbaren Tag aufruft; übergib ein stabiles `busy`-Array (aus State oder deinem Daten-Hook), sonst wird der Cache jedes Mal zurückgesetzt. Und `date` am Timezone Select sorgt dafür, dass die Offsets in der Liste der Sommerzeit des gewählten Tages folgen, nicht der von heute.

> Live-Demo (Timezone Select): Tipp EST, tokyo oder +5:30 ins Feld: Jede Eingabe findet ihre Zone, und die Offsets folgen der Sommerzeit. Probier sie unter [Timezone Select](https://wingo-ui.com/components/timezone-select) aus und installier sie mit `npx wingo-ui@latest add timezone-select`.

## Warum gehören die Zeiten des Hosts in seine Zeitzone?

Weil sich Offsets verschieben, und zwar nicht überall am selben Tag. 2026 hat Europa die Sommerzeit am 25. Oktober verlassen, die USA am 1. November, also lag Berlin in dieser Woche 5 statt 6 Stunden vor New York. Ein Host, der in New York von 9 bis 17 Uhr arbeitet, war im Oktober von 13:00 bis 21:00 UTC erreichbar und ist es seit dem 1. November von 14:00 bis 22:00 UTC. Speicherst du den UTC-Bereich aus dem Oktober, beginnt ab November jeder Slot auf der Uhr des Hosts eine Stunde zu früh. Speichere `{ timeZone: "America/New_York", start: 9, end: 17 }`, berechne die Zeitpunkte für jeden Tag und schick `start.toISOString()` an den Server. Die Zone der buchenden Person dient nur der Anzeige.

## Wann passt der Time Picker statt Slot-Buttons?

Ein React-Kalender mit Zeitslots zeigt feste Startzeiten, und das ist für öffentliche Buchungen richtig. Der [Time Picker](https://wingo-ui.com/components/time-picker) passt auf die andere Seite: wenn der Host seine Zeiten festlegt oder bei einem Anfrageformular, in dem jede Zeit innerhalb eines Fensters in Ordnung ist. `min` und `max` begrenzen das Fenster, `minuteStep` legt das Raster fest, und `disabledTimes` sperrt einzelne Zeiten wie eine Mittagspause oder gebuchte Slots:

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

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

Auf dem Smartphone öffnet er ein Bottom Sheet mit einrastenden Walzen, statt die Tastatur hochzuholen. Brauchst du Datum und Uhrzeit in einem Feld, schau in unseren [Guide zum shadcn Date Time Picker](https://wingo-ui.com/de/blog/shadcn-date-time-picker) und den [Guide zum React Time Picker](https://wingo-ui.com/de/blog/react-time-picker).

## Wie verhindere ich Doppelbuchungen auf dem Server?

Vergebene Slots auszublenden ist nur ein Hinweis. Zwei Personen können die Seite um 9:00 laden und beide 14 Uhr bestätigen. Lass die Datenbank entscheiden. In Postgres lehnt ein Exclusion Constraint überlappende Zeiträume für denselben Host ab:

```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()
  // auch hier erneut prüfen: innerhalb der Zeiten des Hosts, nach der Mindestvorlaufzeit, im Buchungsfenster,
  // und end aus start plus deiner Dauer ableiten, statt dem Client zu vertrauen
  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` ist der Fehlercode von Postgres für eine Exclusion-Verletzung. Die Grenzen `'[)'` erlauben, dass eine Buchung um 14:30 endet und die nächste um 14:30 beginnt. Antwortet die Route mit 409, wirft das `onSubmit` von oben einen Fehler, die Vorlage zeigt den Text aus `labels.failed`, und die Person geht einen Schritt zurück, um eine andere Zeit zu wählen. Unser [Guide zum Resource Scheduler](https://wingo-ui.com/de/blog/react-resource-scheduler) nutzt denselben Constraint für Räume und Autos.

## Wie sollte die Buchung auf dem Smartphone aussehen?

Hier helfen die Schritte am meisten. In der Demo Booking Page wird der Stepper zu einem kompakten Header „Schritt 1 von 3“, Zurück und Weiter sitzen in einer Leiste, die unten am Screen fixiert ist, und die Liste der Zeitzonen öffnet sich als Bottom Sheet. Die Kalenderzellen wachsen auf Touch-Geräten auf 44px, und ein Wischen wechselt den Monat. Beschränk die Slot-Liste auf zwei oder drei Spalten mit Buttons in voller Breite; eine Woche mit sieben Spalten voller Zeiten passt nicht auf 390px.

## Wann sollte ich stattdessen einen Hosted-Scheduler einbetten?

Selbst bauen lohnt sich, wenn die Buchung zu deinen Produktdaten gehört. Brauchst du vor allem einen Link „Gespräch buchen“, kann ein Hosted-Scheduler am ersten Tag mehr: Er liest belegte Zeiten aus Google- oder Outlook-Kalendern, verschickt Bestätigungen und kümmert sich um Links zum Verschieben und Absagen. Team-Scheduling gibt es in den bezahlten Plänen. Ihre öffentlichen Preisseiten, geprüft am 6. November 2026:

| Option | Kostenloser Plan | Bezahlte Pläne | Quelle |
|---|---|---|---|
| Cal.com | Für immer kostenlos, 1 Nutzer, unbegrenzt viele Kalender | Teams $12 pro Nutzer und Monat, Organizations $28 pro Nutzer und Monat | [cal.com/pricing](https://cal.com/pricing) |
| Calendly | Immer kostenlos, 1 Event-Typ, 1 Kalenderverbindung | Standard $10 und Teams $16 pro Seat und Monat, bei jährlicher Abrechnung | [calendly.com/pricing](https://calendly.com/pricing) |

Bette einen ein, wenn dir Kalendersync wichtiger ist als Design und du damit leben kannst, dass die Buchung in deren System liegt. Bau selbst, wenn du eigene Felder brauchst, dein eigenes Aussehen in Light und Dark Mode, Buchungen neben deinen Nutzern und Zahlungen in deiner Datenbank oder keine Gebühr pro Seat, wenn das Team wächst. Auch mit einem Embed anzufangen und umzuziehen, sobald die Buchung zu einem Kernfeature wird, ist ein vernünftiger Weg.

Weitere Screens, komplett von Anfang bis Ende gebaut, findest du in der [Kategorie Build it](https://wingo-ui.com/de/blog/category/build) und im [Tutorial zum Next.js Admin Dashboard](https://wingo-ui.com/de/blog/nextjs-admin-dashboard-tutorial).

## Wo fange ich an?

Öffne die [Vorlage Demo Booking Page](https://wingo-ui.com/templates/demo-booking-page), wechsle die Zeitzone und geh die drei Schritte auf deinem Smartphone durch. Wenn sie passt, melde dich mit deinem Pro-Konto an, und `npx wingo-ui@latest add demo-booking-page` kopiert sie mit dem Calendar, dem Stepper und den Feldern, die sie nutzt, in dein Projekt, bereit für deine Zeiten und deine Route `/api/bookings`.

## Komponenten in diesem Artikel

- [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. Installation: `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. Installation: `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. Installation: `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. Installation: `npx wingo-ui@latest add timezone-select`

## Häufige Fragen

### Wie baue ich eine Seite für Terminbuchungen in React?

Teil sie in drei Schritte: ein Kalender, der nur Tage mit freien Zeiten aktiviert, eine Liste der Zeitslots für den gewählten Tag und ein kurzes Formular für die Details. Erzeuge die Slots aus den Wochenzeiten des Hosts, entferne vergebene und solche innerhalb der Mindestvorlaufzeit und bestätige die Buchung auf dem Server, bevor du den Erfolgsscreen zeigst.

### Wie sollte ein Buchungskalender mit Zeitzonen umgehen?

Speichere die Zeiten des Hosts als Uhrzeiten in einer IANA-Zone wie America/New_York, nie als festen UTC-Offset. Rechne jeden Slot beim Rendern in die Zeitzone der buchenden Person um und schick die Buchung als ISO-Zeitstempel an deinen Server, damit die Zeitumstellung sie nicht verschieben kann.

### Wie verhindere ich, dass zwei Leute denselben Zeitslot buchen?

Blende vergebene Slots in der UI aus, aber sieh das nur als Hinweis, denn zwei Personen können in derselben Sekunde bestätigen. In Postgres lehnt ein Exclusion Constraint auf einer tstzrange-Spalte überlappende Buchungen für denselben Host ab, und deine API beantwortet die unterlegene Anfrage mit 409.

### Sollte ich Calendly oder Cal.com einbetten, statt die Buchung selbst zu bauen?

Bette einen davon ein, wenn du bald Kalendersync mit Google oder Outlook, Erinnerungen, Links zum Verschieben und Team-Routing brauchst und die Buchung nicht in deiner eigenen Datenbank liegen muss. Bau sie selbst, wenn die Buchung zu deinen Produktdaten gehört, eigene Felder oder ein eigenes Design braucht oder du keine Gebühr pro Seat zahlen willst.

### Gibt es einen shadcn Booking Calendar mit Zeitslots?

Ja, der Block calendar-20 von shadcn/ui kombiniert seinen Calendar mit einer Spalte fester Zeiten im 15-Minuten-Takt und ein paar gebuchten Tagen, aber ohne Zeitzonen, Mindestvorlaufzeit oder Schritt für die Details. Wingo UI wird genauso als Quellcode installiert, und seine Demo Booking Page ergänzt die Zeitzonenrechnung, vergebene Slots, Mindestvorlaufzeit, Validierung und ein Layout fürs Smartphone.

---

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