Every agency developer knows this feeling.
A new client comes in. They want a booking platform — a salon, a clinic, a gym,
a restaurant. Doesn't matter. And you think: "I've built this before."
Because you have. Three times last year. With different stacks, different
architectures, different levels of quality. Each time starting from scratch.
This is the story of how I decided to stop doing that.
The problem with booking systems
On the surface, booking looks simple. A customer picks a date, picks a time,
confirms. Done.
But the moment you go deeper, the complexity explodes:
Availability calculation. You need to know which time slots are free for
each resource (a staff member, a room, equipment). That means reading working
hours, existing bookings, and computing gaps — correctly, for every request.
Race conditions. Two customers book the same slot at the same millisecond.
Who gets it? Without proper handling, both. You now have a double booking.
Multi-tenancy. If you're an agency, you're not building for one client.
You're building the same infrastructure for ten clients. Each one needs isolated
data, separate working hours, separate services.
Payments. If the service has a price, you need Stripe, webhooks, and
payment confirmation before you confirm the booking.
Each of these is solvable. But solving all of them, correctly, every time,
for every client? That's 80-120 hours of plumbing that isn't billable as
innovation.
The architecture decision: row-level multi-tenancy
The first key decision was how to handle multi-tenancy.
Two common approaches:
- Separate database per tenant — maximum isolation, but operationally complex and expensive
- Shared database with tenant_id — simpler to operate, needs careful access control
I went with option 2, using a tenant_id column on every table and
enforcing isolation at the API layer.
// Every query is scoped to the current tenant
const tenantResources = await db
.select()
.from(resources)
.where(eq(resources.tenantId, currentUser.tenantId))
The critical security rule: never trust tenantId from the request body.
Always derive it from the authenticated user's session. This prevents a
malicious actor from accessing another tenant's data by forging a request.
// ❌ Wrong — trusts client input
const { tenantId, resourceId } = body
// ✅ Correct — derives tenantId from the resource itself
const [resource] = await db
.select({ tenantId: resources.tenantId })
.from(resources)
.where(eq(resources.id, resourceId))
.limit(1)
The availability engine
The availability calculator is the heart of any booking system.
The logic:
- Load working hours for the resource on the requested day
- Load existing bookings for that day
- Generate time slots of the requested duration
- Filter out slots that overlap with existing bookings
function getAvailableSlots(
workingHours: { startTime: string; endTime: string }[],
existingBookings: { startTime: Date; endTime: Date }[],
duration: number // minutes
): TimeSlot[] {
const slots: TimeSlot[] = []
for (const hours of workingHours) {
let current = parseTime(hours.startTime)
const end = parseTime(hours.endTime)
while (addMinutes(current, duration) <= end) {
const slotEnd = addMinutes(current, duration)
const hasConflict = existingBookings.some(booking =>
current < booking.endTime && slotEnd > booking.startTime
)
if (!hasConflict) {
slots.push({ startTime: formatTime(current), endTime: formatTime(slotEnd) })
}
current = addMinutes(current, duration)
}
}
return slots
}
Preventing race conditions
The availability check and the booking creation are two separate operations.
Between them, another user could book the same slot.
The solution: wrap the entire booking creation in a database transaction with
a conflict check.
await db.transaction(async (tx) => {
// Check again inside the transaction
const conflict = await tx
.select()
.from(bookings)
.where(
and(
eq(bookings.resourceId, resourceId),
lt(bookings.startTime, endDateTime),
gt(bookings.endTime, startDateTime),
ne(bookings.status, 'cancelled')
)
)
.limit(1)
if (conflict.length > 0) {
throw new Error('SLOT_NOT_AVAILABLE')
}
// Safe to create
await tx.insert(bookings).values({ ... })
})
If two requests arrive simultaneously, the database transaction ensures only
one succeeds. The other gets a clear error and the user is asked to choose
a different slot.
Stripe Checkout integration
For paid bookings, the flow is:
- Customer fills in their details
- We create a pending booking in the database
- We create a Stripe Checkout Session with the booking ID in metadata
- Customer redirects to Stripe, completes payment
- Stripe fires a webhook
- We confirm the booking and send the confirmation email
The key insight: send the confirmation email only after the webhook,
not when the booking is created. A pending booking is not a confirmed booking.
// In the webhook handler
if (event.type === 'checkout.session.completed') {
const { bookingId } = event.data.object.metadata
await db.update(bookings)
.set({ status: 'confirmed', paymentStatus: 'paid' })
.where(eq(bookings.id, bookingId))
await sendConfirmationEmail({ bookingId })
}
The result: SlotKit
After building all of this, I packaged it into SlotKit — a production-ready
Next.js 15 booking SaaS template for agencies.
It includes everything described in this article, plus:
- 5 vertical presets (salon, clinic, fitness, professional, restaurant) — each with pre-seeded services, resources, and working hours
- Admin dashboard for managing bookings, resources, and services
- Multi-language support (EN/IT out of the box)
- Security hardened: Zod validation, rate limiting, OWASP headers
The idea is simple: an agency buys it once, deploys it for every client that
needs booking functionality. No more rebuilding the same plumbing.
If you're interested: slotkit.dev
What I learned
Building this taught me a few things worth sharing:
Database transactions are underused. Most developers check availability
and then create the booking in two separate queries. Wrapping them in a
transaction is the only correct way to prevent race conditions.
Never trust the client. Any identifier that controls data access should
be derived server-side, never taken from the request body.
Emails should reflect reality. Sending a "booking confirmed" email
before the payment clears creates trust problems. The webhook pattern solves
this cleanly.
Happy to answer any questions in the comments.
Top comments (0)