Live demo: dental-scheduling-saas.vercel.app · Source: GitHub
Starting point
Booking apps look trivial until two people click the last slot at the same second. Then the interesting questions show up: who actually gets it, how do you stop a clinic from seeing another clinic's patients, and what happens when a payment succeeds after the hold expired? I built this project as a focused reference implementation that answers exactly those questions — a multi-tenant dental scheduling SaaS where every clinic is a separate tenant under its own path, patients book anonymously, and staff see a read-only list of their upcoming appointments.
The goal was a production-ready core on Vercel, built in a couple of days, that demonstrates four things many "CRUD apps" quietly get wrong: strict multi-tenant isolation, atomic double-booking prevention, correct payment ordering, and real-time availability.
Problem
Each of the four is a place where the naive version breaks in production:
- Concurrency: "check if free, then insert" has a gap between the check and the write. Under load, two requests both pass the check.
- Isolation: if tenant filtering lives in application code, one forgotten
WHERE tenant_id = ?leaks data across clinics. - Payments: if you create the Stripe session first and reserve the slot later, two people can both reach checkout and both pay.
- Freshness: if the calendar only refreshes on reload, people book slots that are already gone.
Solution
The theme throughout: push correctness down to the database and keep the application thin. No cron jobs, no background workers — the guarantees live where the data lives.
1. Double-booking is impossible, not unlikely
A partial unique index on (tenant_id, doctor_id, start_time)
restricted to active rows (WHERE status IN ('pending','confirmed')) means the
database physically refuses a second active booking for the same slot. The race condition isn't
mitigated — it's eliminated. A second concurrent request bounces off the index with a
unique-violation, which the app turns into a clean "slot just taken" message.
Holds expire after 15 minutes. Instead of a cron sweeper, expiry is resolved just-in-time: the booking runs inside a single Postgres function that first expires a stale hold on that exact slot, then inserts the new one — atomically, in one transaction.
2. Tenant isolation the database enforces
Staff authenticate through Supabase Auth with their tenant_id baked into the JWT
(app_metadata). Row Level Security policies compare that claim to
each row, so clinic A literally cannot read clinic B's data — even if the application asked for
it. Patients stay anonymous and never touch tables directly; a server action authoritatively
translates the URL slug into a tenant_id. Three Supabase clients, three trust levels:
anon (realtime only), authenticated (staff reads under RLS), service-role (server-side writes).
3. Reserve first, then charge
The order matters. The slot is reserved as pending before redirecting to
Stripe Checkout, so a second buyer hits the index and never reaches a payment page for a taken
slot. The Stripe session id is written back to the row, and the user is sent to hosted checkout.
4. A webhook that survives the real world
Stripe webhooks get replayed, delayed, and spoofed. This one reads the raw request
body and verifies the signature with constructEvent (replay tolerance
included), then runs a guarded, idempotent update:
SET status='confirmed' WHERE stripe_session_id = $1 AND status='pending'. A replay
updates zero rows and does nothing. The nasty edge — paid after the hold expired — is detected
and logged for a documented refund path.
5. Availability that updates itself
Initial availability is rendered server-side (grid minus active bookings). The instant a slot is taken, the server emits a per-tenant Broadcast event and the slot disappears for everyone watching that doctor — no polling, and the anonymous client never reads a table to do it.
Proving it works
Claims are cheap, so the repo ships a scripted end-to-end test that runs against the live
production deployment: it creates a real booking, asserts a second one for the same slot is
rejected (23505), opens a real Stripe session, posts a signature-verified webhook,
and confirms the row flips to confirmed — then replays the webhook to prove it's
idempotent, and cleans up. 9 of 9 checks pass against production.
Architecture
Next.js 14 App Router (Server Components + Server Actions) on Vercel, Supabase for Postgres, Auth
and Realtime, Stripe Checkout in test mode. The slot grid comes from a static config (Mon–Fri,
9:00–17:00, 30-minute slots, computed deterministically from Europe/Warsaw to a UTC instant) — no
schedule table to keep in sync. The UI is intentionally brutalist (black/white, square, fast) and
fully bilingual PL/EN with a one-click switcher. Deploys are automatic on every push to
main.
Result
A working, deployed multi-tenant SaaS core that holds up under the exact conditions that break naive booking systems. The hard guarantees — no double-booking, no cross-tenant leaks, no double charges — are enforced by the database and a hardened webhook, not by careful application code that one refactor could undo. Built in about two days.
Try it yourself
It's live and runs in Stripe test mode, so there are no real charges:
- Open a clinic at dental-scheduling-saas.vercel.app, pick a slot and book it.
- Pay with the Stripe test card
4242 4242 4242 4242, any future date, any CVC. - Sign in to the staff panel:
alfa@klinika.test/test(andbeta@klinika.test/test) — each clinic sees only its own bookings. - Open the same clinic in two windows and book a slot in one — watch it disappear in the other.
Takeaways
The recurring lesson: the strongest guarantees are the ones the database makes for you. A partial unique index removes a whole class of race conditions that application logic can only ever reduce. RLS turns tenant isolation from a code-review concern into a structural one. A guarded idempotent update makes webhook replays a non-event. Each is a few lines — and each replaces a category of production incident.
The honest limitations (per-doctor schedules, automatic refunds for the paid-after-expiry edge, private realtime channels) are documented as a deliberate enterprise upgrade path rather than hidden — which is itself part of what "production-ready" means.