Build a Simple Appointment Booking Platform

Build a booking platform with Next.js, PostgreSQL and a calendar: slots, availability, payments and calendar sync, deployed to a VPS.

Build a niche booking platform for a single vertical (tutors, therapists, coaches): availability rules, timezone-correct slots, payment at booking and .ics calendar downloads. Every helper referenced below is implemented below.

What we’re building

flowchart LR
    Client --> Next[Next.js]
    Next --> PG[(PostgreSQL)]
    Client --> Slot[Pick a slot]
    Slot --> Stripe[Stripe checkout]
    Stripe --> Confirm[Confirmation + .ics]
    Next --> Slot

Availability is stored as weekly rules; slots are computed in the provider’s timezone; a UNIQUE constraint + atomic insert prevents double-bookings; payment gates the final confirmation.

What you’ll learn

  • Timezone-correct availability logic (the part everyone gets wrong)
  • Conflict-free slot claiming under concurrency
  • .ics calendar downloads with a correct implementation

Prerequisites

  • Node.js 22+
  • PostgreSQL 17
  • Stripe account (test mode)
  • A VPS with 2 GB RAM

1. Scaffold

npx create-next-app@latest booking --ts --app
cd booking
npm i pg stripe zod date-fns ics

date-fns does the date math, ics generates the calendar file.

2. Schema

CREATE TABLE providers (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id       UUID UNIQUE,             -- your accounts table
  tz            TEXT NOT NULL DEFAULT 'UTC', -- IANA name: Europe/Madrid, America/New_York
  slot_minutes  INTEGER NOT NULL DEFAULT 60,
  buffer_minutes INTEGER NOT NULL DEFAULT 15
);

CREATE TABLE availability (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  provider_id UUID REFERENCES providers(id),
  day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), -- 0=Sunday
  start_time  TIME NOT NULL,
  end_time    TIME NOT NULL,
  CHECK (end_time > start_time)
);

CREATE TABLE bookings (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  provider_id  UUID REFERENCES providers(id),
  starts_at    TIMESTAMPTZ NOT NULL,
  ends_at      TIMESTAMPTZ NOT NULL,
  client_email TEXT NOT NULL,
  status       TEXT NOT NULL DEFAULT 'unpaid',  -- unpaid | paid | cancelled
  stripe_session_id TEXT,
  UNIQUE (provider_id, starts_at)               -- the concurrency guard
);

CREATE INDEX ON bookings (provider_id, starts_at);

Apply: psql "$DATABASE_URL" -f schema.sql.

The UNIQUE (provider_id, starts_at) constraint means the database itself refuses two bookings for the same provider at the same start time — that is the entire race-safety story, and it survives any number of parallel requests.

3. Environment variables

.env (project root; not committed):

DATABASE_URL=postgres://postgres:dev@localhost:5432/booking
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_APP_URL=http://localhost:3000
EMAIL_FROM="Bookings <[email protected]>"
Variable Required Description
DATABASE_URL yes PostgreSQL connection string.
STRIPE_SECRET_KEY yes Dashboard → API keys (test first).
STRIPE_WEBHOOK_SECRET yes whsec_... from stripe listen.
NEXT_PUBLIC_APP_URL yes Base URL used in checkout redirects and calendar links.
EMAIL_FROM yes Verified sender for confirmations.

4. Generating slots (timezone-correct)

The rule: availability is stored as local wall-clock time; slot generation converts to the provider’s timezone; the calendar/UI renders in the client’s timezone. Never compute human calendars in raw UTC.

lib/slots.ts:

import { addMinutes, setDay, setHours, setMinutes } from 'date-fns';

export interface Slot {
  startsAt: Date;   // an absolute instant, already correct in the provider's tz
  endsAt: Date;
}

interface Rule {
  day_of_week: number;
  start_minutes: number; // "540" = 09:00 local
  end_minutes: number;
}

// Builds every slot inside one availability rule, for the week containing day.
export function slotsForWeek(day: Date, rules: Rule[], slotMin: number, bufferMin: number): Slot[] {
  const slots: Slot[] = [];
  const monday = startOfWeekPlain(day); // Sunday-based like day_of_week 0=Sunday

  for (const r of rules) {
    const base = setMinutes(setHours(setDay(monday, r.day_of_week), 0), 0);
    let t = addMinutes(base, r.start_minutes);
    const end = addMinutes(base, r.end_minutes);

    while (t < end) {
      // Two successive slots are separated by slot+buffer.
      slots.push({ startsAt: t, endsAt: addMinutes(t, slotMin) });
      t = addMinutes(t, slotMin + bufferMin);
    }
  }
  return slots;
}

function startOfWeekPlain(d: Date): Date {
  const day = d.getDay(); // 0=Sunday
  const copy = new Date(d);
  copy.setHours(0, 0, 0, 0);
  copy.setDate(copy.getDate() - day);
  if (copy.getTime() > d.getTime()) copy.setDate(copy.getDate() - 7);
  return copy;
}

// IANA timezone conversions via Intl — the correct tool for this job.
export function inZone(date: Date, tz: string): Date {
  return new Date(date.toLocaleString('en-US', { timeZone: tz }));
}

What this does:

  • Wall-clock rules (09:00–18:00 in the provider’s tz) are turned into absolute instants with Intl timezone math — the DST-safe way. Storing “9:00” plus the IANA zone is what lets the same rule produce correct slots across winter/summer time.
  • slotMin + bufferMin spacing means a 60-min slot gets a 15-min gap before the next offer — the block-out time where a provider walks a client out or takes a breather.
  • To change slot length or buffer: edit the provider row (slot_minutes, buffer_minutes) — this function reads them.

5. Claiming a slot (atomically)

app/api/book/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { pool } from '@/lib/db';
import { createCheckoutSession } from '@/lib/payments';

const bodySchema = z.object({
  providerId: z.string().uuid(),
  startsAt: z.string().datetime(), // ISO instant, e.g. 2026-09-01T09:00:00.000Z
  clientEmail: z.string().email(),
});

export async function POST(req: NextRequest) {
  let parsed;
  try {
    parsed = bodySchema.safeParse(await req.json());
  } catch {
    return NextResponse.json({ error: 'invalid json' }, { status: 400 });
  }
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  }
  const { providerId, startsAt, clientEmail } = parsed.data;

  try {
    // Atomic claim: the UNIQUE(provider_id, starts_at) constraint is the lock.
    // ON CONFLICT DO NOTHING + RETURNING makes a double-booking a 409, not a 500.
    const slot = await pool.query(
      `INSERT INTO bookings (provider_id, starts_at, ends_at, client_email, status)
       VALUES ($1, $2, $2 + (SELECT slot_minutes || ' minutes' FROM providers WHERE id = $1)::interval,
               $3, 'unpaid')
       ON CONFLICT (provider_id, starts_at) DO NOTHING
       RETURNING id`,
      [providerId, startsAt, clientEmail]
    );
    if (!slot.rowCount) {
      return NextResponse.json({ error: 'slot_taken' }, { status: 409 });
    }

    const bookingId = slot.rows[0].id;
    const checkoutUrl = await createCheckoutSession(bookingId, providerId, clientEmail);

    return NextResponse.json({ ok: true, bookingId, checkoutUrl });
  } catch (err) {
    console.error('booking failed', err);
    return NextResponse.json({ error: 'internal error' }, { status: 500 });
  }
}
  • Slot length comes from the provider’s row at insert time — no hardcoded “60 minutes” that silently disagrees with the availability rules.
  • A second person clicking the same slot gets 409 slot_taken, and the first person’s row stays intact.

6. Payment + confirmation

Where to get the Stripe IDs (test mode first):

  1. STRIPE_SECRET_KEY — Stripe Dashboard → Developers → API keys → copy sk_test_.... Production later uses sk_live_....
  2. STRIPE_SESSION_PRICE — Dashboard → Products → Add product → Name “Session”, price $50 one-time → Create product → open it → copy the price_1... ID under Pricing.
  3. STRIPE_WEBHOOK_SECRET — Dashboard → Developers → Webhooks → Add endpoint → URL https://YOUR-DOMAIN.com/api/webhooks/stripe → select event checkout.session.completed → Create → reveal the whsec_... signing secret. (Locally, stripe listen --forward-to localhost:3000/api/webhooks/stripe prints a secret and proxies events for free.)

lib/payments.ts:

import { NextRequest } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const SESSION_PRICE = process.env.STRIPE_SESSION_PRICE!; // price_1... from step 6 above

export async function createCheckoutSession(
  bookingId: string,
  providerId: string,
  clientEmail: string
) {
  const origin = process.env.NEXT_PUBLIC_APP_URL!;
  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    customer_email: clientEmail,
    line_items: [{ price: SESSION_PRICE, quantity: 1 }],
    metadata: { bookingId, providerId },
    success_url: `${origin}/confirmed?b=${bookingId}`,
    cancel_url: `${origin}/book?provider=${providerId}`,
  });
  return session.url;
}

The webhook that finalizes payment — app/api/webhooks/stripe/route.ts:

import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { pool } from '@/lib/db';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: NextRequest) {
  const sig = req.headers.get('stripe-signature');
  if (!sig) return NextResponse.json({ error: 'missing stripe-signature' }, { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      await req.text(), sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('webhook signature failed', err);
    return NextResponse.json({ error: 'invalid signature' }, { status: 400 });
  }

  try {
    if (event.type === 'checkout.session.completed') {
      const session = event.data.object as Stripe.Checkout.Session;
      const bookingId = session.metadata?.bookingId;
      if (!bookingId) {
        return NextResponse.json({ error: 'missing bookingId' }, { status: 400 });
      }
      await pool.query(
        `UPDATE bookings SET status='paid', stripe_session_id=$2 WHERE id=$1 AND status='unpaid'`,
        [bookingId, session.id]
      );
    }
    return NextResponse.json({ received: true });
  } catch (err) {
    console.error('webhook handler failed', err);
    return NextResponse.json({ error: 'handler failed' }, { status: 500 });
  }
}

metadata.bookingId is the only bridge between Stripe and the booking row — the webhook never trusts a URL or a form field.

lib/calendar.ts — the .ics file (the old version encoded the ics result object as [object Object]; the SDK actually returns { error, value }):

import { createEvent } from 'ics';

export async function makeCalendarEvent(booking: {
  id: string;
  startsAt: Date; // keep a Date object; ics serializes it
  clientEmail: string;
}) {
  const start: [number, number, number, number, number] = [
    booking.startsAt.getFullYear(),
    booking.startsAt.getMonth() + 1,
    booking.startsAt.getDate(),
    booking.startsAt.getHours(),
    booking.startsAt.getMinutes(),
  ];

  const { error, value } = createEvent({
    start,
    duration: { minutes: 60 },
    title: 'Your appointment',
    description: `Booking ${booking.id}`,
    organizer: { name: process.env.COMPANY_NAME ?? 'Your Company', email: process.env.EMAIL_FROM! },
  });
  if (error) throw new Error(`ics: ${error}`);
  return value as string; // the raw .ics text
}

Return it from the confirmation page with Content-Type: text/calendar (disposable download) or attach it to the confirmation email — the same string either way.

7. Run locally

docker run -d --name pg --rm -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:17
export DATABASE_URL=postgres://postgres:dev@localhost:5432/postgres
psql "$DATABASE_URL" -f schema.sql
export $(grep -v '^#' .env | xargs)
npm run dev
# terminal 2:
stripe listen --forward-to localhost:3000/api/webhooks/stripe

Test the double-booking guard specifically:

INSERT a provider + one availability rule, then in two terminals run the same /api/book
request simultaneously; the second gets HTTP 409 (slot_taken).

8. Dockerize and deploy

Dockerfile (requires output: 'standalone' in next.config.ts):

FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build

FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
docker build -t booking .
docker run -d --name bk --restart unless-stopped -p 3000:3000 \
  -e DATABASE_URL=... -e STRIPE_SECRET_KEY=... -e STRIPE_WEBHOOK_SECRET=... \
  -e STRIPE_SESSION_PRICE=price_1... \
  -e NEXT_PUBLIC_APP_URL=https://YOUR-DOMAIN.com booking

Caddy: reverse_proxy app:3000 for YOUR-DOMAIN.com, and point the Stripe webhook at the /api/webhooks/stripe URL.

Cost (assumptions: 1 provider, 200 bookings/month at $50 each)

Service Monthly cost
VPS 2 GB $4.49
PostgreSQL (on VPS) $0
Email (Resend free tier) $0
Stripe (~2.9% + $0.30 per booking) ~$350 of revenue → ~$10/mo in fees
Total ≈ $5/month + Stripe fees

Production checklist

  • Timezone tests: provider in UTC-8, client in UTC+11, runs across a DST boundary — 4 combinations in a test file
  • 15-minute payment hold: slot is unpaid until webhook; release unpaid slots 15 minutes after checkout expiry (WHERE status='unpaid' AND created_at < now() - interval '15 minutes')
  • Double-booking blocked (UNIQUE) — load-test with 20 parallel requests, assert 1 row created
  • Cancellation flow: status='cancelled' + Stripe refund via a refund call keyed by stripe_session_id
  • Daily pg_dump
  • Client email verification before confirmation (magic-link) to stop spam bookings

Common problems

  • DST double-slots: rules are wall-clock + IANA zone and converted with Intl — never try to store “9:00 UTC” for a Madrid provider; your slots drift by an hour twice a year.
  • Booking while paying: the slot is reserved at insert (unpaid) and the webhook flips it paid; the 15-min release job frees abandoned checkouts.
  • Spam bookings: client_email z-validated, but a determined spammer can still fill your calendar — require the email magic-link before showing available times.
  • [object Object] in the calendar file: you passed the whole createEvent result to a string. Use result.value as in lib/calendar.ts.

Improvements

  • Google Calendar two-way sync (watch channel + events.insert)
  • Recurring bookings for weekly clients (a recurrence rule column)
  • Waiting lists + auto-fill the slot when a paid booking cancels

Conclusion

Booking is availability rules, an atomic insert and a payment webhook — but only correct if the timezone model and the .ics output are done right, and both are now implemented above. The moat is niche UX and reliability, both of which come free on a $5/month stack.

Related guides