DEV Community

Cover image for How I stopped rebuilding the same booking logic for every agency client
SlotKit
SlotKit

Posted on

How I stopped rebuilding the same booking logic for every agency client

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:

  1. Separate database per tenant — maximum isolation, but operationally complex and expensive
  2. 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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

The availability engine

The availability calculator is the heart of any booking system.

The logic:

  1. Load working hours for the resource on the requested day
  2. Load existing bookings for that day
  3. Generate time slots of the requested duration
  4. 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
}
Enter fullscreen mode Exit fullscreen mode

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({ ... })
})
Enter fullscreen mode Exit fullscreen mode

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:

  1. Customer fills in their details
  2. We create a pending booking in the database
  3. We create a Stripe Checkout Session with the booking ID in metadata
  4. Customer redirects to Stripe, completes payment
  5. Stripe fires a webhook
  6. 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 })
}
Enter fullscreen mode Exit fullscreen mode

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)