← Portfolio Case Study

Dental Scheduling SaaS — multi-tenant booking that can't double-book

Klient: Internal project / reference implementation Czas: ~2 days
Next.js 14 TypeScript Supabase PostgreSQL RLS Stripe Realtime Vercel

Before

  • Two patients pay for the same slot under load (race condition)
  • Tenant data leaks because isolation lives in app code, not the database
  • Availability goes stale — users book ghosts, then get refunded
  • Payment gets created before the slot is reserved → double charges

After

  • One active booking per slot — enforced by the database, not by hope
  • Hard tenant isolation via Postgres RLS on a JWT claim
  • Live availability — a slot vanishes the instant someone takes it
  • Slot reserved before Stripe; webhook idempotent and replay-safe
  • Verified against production: 9/9 automated end-to-end checks

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 (and beta@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.

Have a similar problem?

Describe it to AI — it gathers technical context, Artur delivers a quote in 48h.

Start diagnostics