A booking system is essential for service-based businesses. This tutorial covers time slot management, conflict prevention, and confirmation workflows.
Step 1: Database Schema
// db/schema.ts
import { pgTable, text, timestamp, integer, boolean, uuid } from "drizzle-orm/pg-core";
export const services = pgTable("services", {
id: uuid("id").defaultRandom().primaryKey(),
name: text("name").notNull(),
duration: integer("duration").notNull(), // minutes
price: integer("price").notNull(), // cents
description: text("description"),
active: boolean("active").default(true),
});
export const availability = pgTable("availability", {
id: uuid("id").defaultRandom().primaryKey(),
dayOfWeek: integer("day_of_week").notNull(), // 0=Sunday, 6=Saturday
startTime: text("start_time").notNull(), // "09:00"
endTime: text("end_time").notNull(), // "17:00"
active: boolean("active").default(true),
});
export const bookings = pgTable("bookings", {
id: uuid("id").defaultRandom().primaryKey(),
serviceId: uuid("service_id").references(() => services.id).notNull(),
customerName: text("customer_name").notNull(),
customerEmail: text("customer_email").notNull(),
customerPhone: text("customer_phone"),
startTime: timestamp("start_time").notNull(),
endTime: timestamp("end_time").notNull(),
status: text("status", { enum: ["confirmed", "cancelled", "completed"] })
.default("confirmed")
.notNull(),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(),
});
export const blockedDates = pgTable("blocked_dates", {
id: uuid("id").defaultRandom().primaryKey(),
date: timestamp("date").notNull(),
reason: text("reason"),
});
Step 2: Availability Logic
// lib/booking.ts
import { db } from "@/db";
import { bookings, availability, blockedDates, services } from "@/db/schema";
import { eq, and, gte, lt, ne } from "drizzle-orm";
export async function getAvailableSlots(serviceId: string, date: Date) {
// Check if date is blocked
const startOfDay = new Date(date);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(date);
endOfDay.setHours(23, 59, 59, 999);
const blocked = await db
.select()
.from(blockedDates)
.where(and(gte(blockedDates.date, startOfDay), lt(blockedDates.date, endOfDay)));
if (blocked.length > 0) return [];
// Get service duration
const [service] = await db
.select()
.from(services)
.where(eq(services.id, serviceId));
if (!service) return [];
// Get availability for this day of week
const dayOfWeek = date.getDay();
const dayAvailability = await db
.select()
.from(availability)
.where(
and(eq(availability.dayOfWeek, dayOfWeek), eq(availability.active, true))
);
if (dayAvailability.length === 0) return [];
// Get existing bookings for this date
const existingBookings = await db
.select()
.from(bookings)
.where(
and(
gte(bookings.startTime, startOfDay),
lt(bookings.startTime, endOfDay),
ne(bookings.status, "cancelled")
)
);
// Generate time slots
const slots: { start: Date; end: Date }[] = [];
for (const avail of dayAvailability) {
const [startHour, startMin] = avail.startTime.split(":").map(Number);
const [endHour, endMin] = avail.endTime.split(":").map(Number);
const slotStart = new Date(date);
slotStart.setHours(startHour, startMin, 0, 0);
const windowEnd = new Date(date);
windowEnd.setHours(endHour, endMin, 0, 0);
while (slotStart.getTime() + service.duration * 60000 <= windowEnd.getTime()) {
const slotEnd = new Date(slotStart.getTime() + service.duration * 60000);
// Check for conflicts
const hasConflict = existingBookings.some(
(booking) =>
slotStart < booking.endTime && slotEnd > booking.startTime
);
if (!hasConflict) {
slots.push({
start: new Date(slotStart),
end: new Date(slotEnd),
});
}
// Move to next slot (30 min increments)
slotStart.setMinutes(slotStart.getMinutes() + 30);
}
}
return slots;
}
Step 3: Booking API Routes
// app/api/bookings/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { bookings, services } from "@/db/schema";
import { eq, and, gte, lt, ne } from "drizzle-orm";
import { z } from "zod";
const bookingSchema = z.object({
serviceId: z.string().uuid(),
customerName: z.string().min(2).max(100),
customerEmail: z.string().email(),
customerPhone: z.string().optional(),
startTime: z.string().datetime(),
notes: z.string().max(500).optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const parsed = bookingSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const { serviceId, customerName, customerEmail, customerPhone, startTime, notes } =
parsed.data;
// Get service
const [service] = await db
.select()
.from(services)
.where(eq(services.id, serviceId));
if (!service) {
return NextResponse.json({ error: "Service not found" }, { status: 404 });
}
const start = new Date(startTime);
const end = new Date(start.getTime() + service.duration * 60000);
// Check for conflicts
const conflicts = await db
.select()
.from(bookings)
.where(
and(
lt(bookings.startTime, end),
gte(bookings.endTime, start),
ne(bookings.status, "cancelled")
)
);
if (conflicts.length > 0) {
return NextResponse.json(
{ error: "This time slot is no longer available" },
{ status: 409 }
);
}
// Create booking
const [booking] = await db
.insert(bookings)
.values({
serviceId,
customerName,
customerEmail,
customerPhone,
startTime: start,
endTime: end,
notes,
})
.returning();
// TODO: Send confirmation email
return NextResponse.json({ booking }, { status: 201 });
}
Step 4: Available Slots API
// app/api/bookings/slots/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAvailableSlots } from "@/lib/booking";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const serviceId = searchParams.get("serviceId");
const date = searchParams.get("date");
if (!serviceId || !date) {
return NextResponse.json(
{ error: "serviceId and date are required" },
{ status: 400 }
);
}
const slots = await getAvailableSlots(serviceId, new Date(date));
return NextResponse.json({ slots });
}
Step 5: Service Selection Component
// components/booking/ServiceSelect.tsx
"use client";
interface Service {
id: string;
name: string;
duration: number;
price: number;
description: string | null;
}
interface ServiceSelectProps {
services: Service[];
selectedId: string | null;
onSelect: (id: string) => void;
}
export function ServiceSelect({ services, selectedId, onSelect }: ServiceSelectProps) {
return (
<div className="space-y-3">
<h2 className="text-lg font-semibold">Select a Service</h2>
<div className="grid gap-3 sm:grid-cols-2">
{services.map((service) => (
<button
key={service.id}
onClick={() => onSelect(service.id)}
className={`rounded-lg border p-4 text-left transition ${
selectedId === service.id
? "border-blue-600 bg-blue-50 ring-2 ring-blue-600"
: "border-gray-200 hover:border-gray-300"
}`}
>
<div className="font-medium">{service.name}</div>
{service.description && (
<p className="mt-1 text-sm text-gray-600">{service.description}</p>
)}
<div className="mt-2 flex items-center gap-3 text-sm text-gray-500">
<span>{service.duration} min</span>
<span>${(service.price / 100).toFixed(2)}</span>
</div>
</button>
))}
</div>
</div>
);
}
Step 6: Date and Time Picker
// components/booking/DateTimePicker.tsx
"use client";
import { useState, useEffect } from "react";
interface Slot {
start: string;
end: string;
}
interface DateTimePickerProps {
serviceId: string;
onSelect: (slot: Slot) => void;
selectedSlot: Slot | null;
}
export function DateTimePicker({ serviceId, onSelect, selectedSlot }: DateTimePickerProps) {
const [selectedDate, setSelectedDate] = useState<string>("");
const [slots, setSlots] = useState<Slot[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!selectedDate || !serviceId) return;
setLoading(true);
fetch(`/api/bookings/slots?serviceId=${serviceId}&date=${selectedDate}`)
.then((res) => res.json())
.then((data) => setSlots(data.slots))
.finally(() => setLoading(false));
}, [selectedDate, serviceId]);
// Next 30 days
const dates = Array.from({ length: 30 }, (_, i) => {
const d = new Date();
d.setDate(d.getDate() + i + 1);
return d.toISOString().split("T")[0];
});
return (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Select a Date</h2>
<div className="mt-2 flex gap-2 overflow-x-auto pb-2">
{dates.map((date) => {
const d = new Date(date + "T12:00:00");
return (
<button
key={date}
onClick={() => setSelectedDate(date)}
className={`shrink-0 rounded-lg border px-3 py-2 text-center ${
selectedDate === date
? "border-blue-600 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
<div className="text-xs text-gray-500">
{d.toLocaleDateString("en", { weekday: "short" })}
</div>
<div className="text-sm font-medium">
{d.toLocaleDateString("en", { month: "short", day: "numeric" })}
</div>
</button>
);
})}
</div>
</div>
{selectedDate && (
<div>
<h2 className="text-lg font-semibold">Select a Time</h2>
{loading ? (
<p className="mt-2 text-sm text-gray-500">Loading available times...</p>
) : slots.length === 0 ? (
<p className="mt-2 text-sm text-gray-500">No available times for this date.</p>
) : (
<div className="mt-2 grid grid-cols-3 gap-2 sm:grid-cols-4">
{slots.map((slot) => (
<button
key={slot.start}
onClick={() => onSelect(slot)}
className={`rounded-lg border px-3 py-2 text-sm ${
selectedSlot?.start === slot.start
? "border-blue-600 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
}`}
>
{new Date(slot.start).toLocaleTimeString("en", {
hour: "numeric",
minute: "2-digit",
})}
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
Step 7: Booking Form
// components/booking/BookingForm.tsx
"use client";
import { useState } from "react";
import { ServiceSelect } from "./ServiceSelect";
import { DateTimePicker } from "./DateTimePicker";
interface Service {
id: string;
name: string;
duration: number;
price: number;
description: string | null;
}
interface Slot {
start: string;
end: string;
}
export function BookingForm({ services }: { services: Service[] }) {
const [step, setStep] = useState(1);
const [serviceId, setServiceId] = useState<string | null>(null);
const [selectedSlot, setSelectedSlot] = useState<Slot | null>(null);
const [customerName, setCustomerName] = useState("");
const [customerEmail, setCustomerEmail] = useState("");
const [customerPhone, setCustomerPhone] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [confirmed, setConfirmed] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!serviceId || !selectedSlot) return;
setSubmitting(true);
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
serviceId,
customerName,
customerEmail,
customerPhone: customerPhone || undefined,
startTime: selectedSlot.start,
notes: notes || undefined,
}),
});
if (res.ok) {
setConfirmed(true);
} else {
const data = await res.json();
alert(data.error ?? "Failed to book. Please try again.");
}
setSubmitting(false);
}
if (confirmed) {
return (
<div className="rounded-lg border border-green-200 bg-green-50 p-6 text-center">
<h2 className="text-xl font-semibold text-green-800">Booking Confirmed</h2>
<p className="mt-2 text-green-700">
A confirmation email has been sent to {customerEmail}.
</p>
</div>
);
}
return (
<div className="mx-auto max-w-2xl space-y-8">
{/* Step indicators */}
<div className="flex items-center justify-center gap-2">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium ${
step === s
? "bg-blue-600 text-white"
: step > s
? "bg-blue-100 text-blue-600"
: "bg-gray-100 text-gray-400"
}`}
>
{s}
</div>
))}
</div>
{step === 1 && (
<>
<ServiceSelect
services={services}
selectedId={serviceId}
onSelect={(id) => {
setServiceId(id);
setStep(2);
}}
/>
</>
)}
{step === 2 && serviceId && (
<>
<DateTimePicker
serviceId={serviceId}
onSelect={(slot) => {
setSelectedSlot(slot);
setStep(3);
}}
selectedSlot={selectedSlot}
/>
<button onClick={() => setStep(1)} className="text-sm text-gray-500 hover:underline">
Back to services
</button>
</>
)}
{step === 3 && (
<form onSubmit={handleSubmit} className="space-y-4">
<h2 className="text-lg font-semibold">Your Details</h2>
<div>
<label htmlFor="name" className="mb-1 block text-sm font-medium">Name</label>
<input
id="name"
type="text"
required
value={customerName}
onChange={(e) => setCustomerName(e.target.value)}
className="w-full rounded-lg border px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="mb-1 block text-sm font-medium">Email</label>
<input
id="email"
type="email"
required
value={customerEmail}
onChange={(e) => setCustomerEmail(e.target.value)}
className="w-full rounded-lg border px-3 py-2"
/>
</div>
<div>
<label htmlFor="phone" className="mb-1 block text-sm font-medium">Phone (optional)</label>
<input
id="phone"
type="tel"
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
className="w-full rounded-lg border px-3 py-2"
/>
</div>
<div>
<label htmlFor="notes" className="mb-1 block text-sm font-medium">Notes (optional)</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
className="w-full rounded-lg border px-3 py-2"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => setStep(2)}
className="rounded-lg border px-4 py-2 text-sm hover:bg-gray-50"
>
Back
</button>
<button
type="submit"
disabled={submitting}
className="rounded-lg bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{submitting ? "Booking..." : "Confirm Booking"}
</button>
</div>
</form>
)}
</div>
);
}
Need a Booking System?
We build custom booking and scheduling systems tailored to your business. Get in touch to discuss your project.