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
.icscalendar 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
.icscalendar 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:00in the provider’s tz) are turned into absolute instants withIntltimezone 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 + bufferMinspacing 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):
STRIPE_SECRET_KEY— Stripe Dashboard → Developers → API keys → copysk_test_.... Production later usessk_live_....STRIPE_SESSION_PRICE— Dashboard → Products → Add product → Name “Session”, price $50 one-time → Create product → open it → copy theprice_1...ID under Pricing.STRIPE_WEBHOOK_SECRET— Dashboard → Developers → Webhooks → Add endpoint → URLhttps://YOUR-DOMAIN.com/api/webhooks/stripe→ select eventcheckout.session.completed→ Create → reveal thewhsec_...signing secret. (Locally,stripe listen --forward-to localhost:3000/api/webhooks/stripeprints 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
unpaiduntil 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 arefundcall keyed bystripe_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 itpaid; the 15-min release job frees abandoned checkouts. - Spam bookings:
client_emailz-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 wholecreateEventresult to a string. Useresult.valueas inlib/calendar.ts.
Improvements
- Google Calendar two-way sync (watch channel +
events.insert) - Recurring bookings for weekly clients (a
recurrencerule 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.