Skip to main content
Back to Blog
Tutorials
6 min read
November 22, 2024

How to Build a Booking and Scheduling System in Next.js

Build a complete booking and scheduling system with time slot management, availability checking, and confirmation emails in Next.js.

Ryel Banfield

Founder & Lead Developer

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.

booking systemschedulingcalendarNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles