# How to Build Booking platform with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a service booking platform with V0 using Next.js, Supabase, and Stripe. You'll get a public booking flow with availability calendar, time slot picker, payment collection, automated email confirmations, and a provider management dashboard — all in about 1-2 hours.

## Before you start

- A V0 account (Premium plan recommended for multi-page builds)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works for development)
- A Resend account for email confirmations (free tier: 3,000 emails/month)

## Step-by-step guide

### 1. Set up the database schema with availability and booking tables

Create a new V0 project, connect Supabase and Stripe via the Connect panel. Create the providers, services, availability, and bookings tables with an exclusion constraint that prevents overlapping bookings for the same provider.

```
// Paste this prompt into V0's AI chat:
// Build a booking platform with Supabase. Create these tables:
// 1. providers: id (uuid PK), user_id (uuid FK to auth.users), name (text), bio (text), avatar_url (text), timezone (text default 'UTC')
// 2. services: id (uuid PK), provider_id (uuid FK), name (text), duration_minutes (int), price (numeric), description (text), is_active (boolean default true)
// 3. availability: id (uuid PK), provider_id (uuid FK), day_of_week (int 0-6), start_time (time), end_time (time)
// 4. bookings: id (uuid PK), service_id (uuid FK), provider_id (uuid FK), customer_id (uuid FK), starts_at (timestamptz), ends_at (timestamptz), status (text CHECK in 'pending','confirmed','cancelled','completed'), notes (text), stripe_payment_intent_id (text), created_at (timestamptz)
// Add exclusion constraint on bookings: EXCLUDE USING gist (provider_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE (status != 'cancelled')
// Add RLS so customers see only their own bookings, providers see their own.
```

> Pro tip: The GiST exclusion constraint is the key to preventing double-bookings. It ensures that no two non-cancelled bookings for the same provider can have overlapping time ranges — enforced at the database level.

**Expected result:** Supabase and Stripe are connected. Tables are created with the exclusion constraint preventing overlapping bookings per provider.

### 2. Build the public booking flow with service and time slot selection

Create the customer-facing booking pages. Customers first select a provider and service, then pick a date from a Calendar, and finally choose an available time slot. Available slots are calculated by checking the provider's weekly availability against existing bookings.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const providerId = searchParams.get('provider_id')!
  const serviceId = searchParams.get('service_id')!
  const date = searchParams.get('date')!

  const dayOfWeek = new Date(date).getDay()

  const { data: availability } = await supabase
    .from('availability')
    .select('start_time, end_time')
    .eq('provider_id', providerId)
    .eq('day_of_week', dayOfWeek)

  const { data: service } = await supabase
    .from('services')
    .select('duration_minutes')
    .eq('id', serviceId)
    .single()

  if (!availability?.length || !service) {
    return NextResponse.json({ slots: [] })
  }

  const { data: existingBookings } = await supabase
    .from('bookings')
    .select('starts_at, ends_at')
    .eq('provider_id', providerId)
    .neq('status', 'cancelled')
    .gte('starts_at', `${date}T00:00:00`)
    .lte('starts_at', `${date}T23:59:59`)

  const slots: string[] = []
  for (const block of availability) {
    let current = new Date(`${date}T${block.start_time}`)
    const end = new Date(`${date}T${block.end_time}`)

    while (current.getTime() + service.duration_minutes * 60000 <= end.getTime()) {
      const slotEnd = new Date(current.getTime() + service.duration_minutes * 60000)
      const conflicts = existingBookings?.some((b) => {
        const bStart = new Date(b.starts_at).getTime()
        const bEnd = new Date(b.ends_at).getTime()
        return current.getTime() < bEnd && slotEnd.getTime() > bStart
      })
      if (!conflicts) slots.push(current.toISOString())
      current = new Date(current.getTime() + 30 * 60000)
    }
  }

  return NextResponse.json({ slots })
}
```

**Expected result:** The API returns available time slots for a given provider, service, and date by cross-referencing weekly availability with existing bookings.

### 3. Create the booking confirmation with Stripe payment

When a customer selects a time slot, create a Stripe Checkout Session that includes the service price and booking details. After payment, the Stripe webhook confirms the booking and sends a confirmation email.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(req: NextRequest) {
  const { serviceId, providerId, customerId, startsAt, endsAt, serviceName, price } = await req.json()

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    payment_method_types: ['card'],
    line_items: [{
      price_data: {
        currency: 'usd',
        product_data: { name: serviceName },
        unit_amount: Math.round(price * 100),
      },
      quantity: 1,
    }],
    success_url: `${req.nextUrl.origin}/bookings?success=true`,
    cancel_url: `${req.nextUrl.origin}/book/${providerId}`,
    metadata: {
      service_id: serviceId,
      provider_id: providerId,
      customer_id: customerId,
      starts_at: startsAt,
      ends_at: endsAt,
    },
  })

  return NextResponse.json({ url: session.url })
}
```

> Pro tip: Use prompt queuing in V0 — queue the booking flow UI, the slot calculation API, and the Stripe checkout separately so V0 generates each feature cleanly without context confusion.

**Expected result:** Selecting a time slot creates a Stripe Checkout Session. After payment, the customer is redirected to a success page.

### 4. Build the webhook handler and email confirmation

Create the Stripe webhook handler that confirms bookings after payment and sends email confirmations to both the customer and provider using Resend.

```
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
import { createClient } from '@supabase/supabase-js'
import { Resend } from 'resend'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)
const resend = new Resend(process.env.RESEND_API_KEY)

export async function POST(req: NextRequest) {
  const body = await req.text()
  const sig = req.headers.get('stripe-signature')!

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!)
  } catch {
    return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
  }

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session
    const meta = session.metadata!

    const { data: booking, error } = await supabase.from('bookings').insert({
      service_id: meta.service_id,
      provider_id: meta.provider_id,
      customer_id: meta.customer_id,
      starts_at: meta.starts_at,
      ends_at: meta.ends_at,
      status: 'confirmed',
      stripe_payment_intent_id: session.payment_intent as string,
    }).select().single()

    if (!error && session.customer_details?.email) {
      await resend.emails.send({
        from: 'bookings@yourdomain.com',
        to: session.customer_details.email,
        subject: 'Booking Confirmed',
        text: `Your booking is confirmed for ${new Date(meta.starts_at).toLocaleString()}.`,
      })
    }
  }

  return NextResponse.json({ received: true })
}
```

**Expected result:** After successful payment, a booking record is created in Supabase with status confirmed, and the customer receives a confirmation email.

### 5. Build the provider dashboard for managing bookings and availability

Create the provider-facing dashboard where they can set their weekly availability, view upcoming bookings, and update booking status (confirm, complete, cancel). Use Server Components for data fetching.

```
// Paste this prompt into V0's AI chat:
// Build a provider dashboard at app/dashboard/bookings/page.tsx.
// Requirements:
// - Calendar view showing upcoming bookings as colored blocks
// - Table view with columns: Date, Time, Customer, Service, Status Badge, Actions
// - Tabs to switch between Calendar and Table views
// - Availability management page with day-of-week Select, start/end time pickers for each day
// - Server Actions for updating booking status (confirm, complete, cancel)
// - Cancel action should show AlertDialog with confirmation
// - Badge colors: pending=yellow, confirmed=green, cancelled=red, completed=blue
// - Use Server Components for initial data fetch from Supabase
```

**Expected result:** The provider dashboard shows upcoming bookings in both calendar and table views. Providers can manage their weekly availability and update booking statuses.

## Complete code example

File: `app/api/slots/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

export async function GET(req: NextRequest) {
  const { searchParams } = new URL(req.url)
  const providerId = searchParams.get('provider_id')!
  const serviceId = searchParams.get('service_id')!
  const date = searchParams.get('date')!

  const dayOfWeek = new Date(date).getDay()

  const [{ data: availability }, { data: service }, { data: bookings }] =
    await Promise.all([
      supabase
        .from('availability')
        .select('start_time, end_time')
        .eq('provider_id', providerId)
        .eq('day_of_week', dayOfWeek),
      supabase
        .from('services')
        .select('duration_minutes')
        .eq('id', serviceId)
        .single(),
      supabase
        .from('bookings')
        .select('starts_at, ends_at')
        .eq('provider_id', providerId)
        .neq('status', 'cancelled')
        .gte('starts_at', `${date}T00:00:00`)
        .lte('starts_at', `${date}T23:59:59`),
    ])

  if (!availability?.length || !service) {
    return NextResponse.json({ slots: [] })
  }

  const slots: string[] = []
  const duration = service.duration_minutes * 60000

  for (const block of availability) {
    let current = new Date(`${date}T${block.start_time}`)
    const end = new Date(`${date}T${block.end_time}`)

    while (current.getTime() + duration <= end.getTime()) {
      const slotEnd = new Date(current.getTime() + duration)
      const hasConflict = bookings?.some((b) => {
        const bStart = new Date(b.starts_at).getTime()
        const bEnd = new Date(b.ends_at).getTime()
        return current.getTime() < bEnd && slotEnd.getTime() > bStart
      })
      if (!hasConflict) slots.push(current.toISOString())
      current = new Date(current.getTime() + 30 * 60000)
    }
  }

  return NextResponse.json({ slots })
}
```

## Common mistakes

- **Checking for availability only in application code** — Application-level availability checks create race conditions where two customers booking simultaneously both see the slot as available and both bookings succeed. Fix: Use a PostgreSQL exclusion constraint: EXCLUDE USING gist (provider_id WITH =, tstzrange(starts_at, ends_at) WITH &&). This prevents overlapping bookings at the database level regardless of application timing.
- **Creating the booking before payment is confirmed** — If you create the booking when the customer clicks book and then redirect to Stripe, the slot is reserved even if they abandon payment. This blocks the slot for other customers. Fix: Create the booking only in the Stripe webhook after payment is confirmed. Pass all booking details in the Checkout Session metadata so the webhook has everything it needs.
- **Not handling timezone differences between providers and customers** — If the provider is in EST and the customer is in PST, slots displayed without timezone conversion will be off by 3 hours. Fix: Store the provider's timezone in the providers table. Convert availability times to UTC for database operations, and display them in the customer's local timezone on the frontend using Intl.DateTimeFormat.

## Best practices

- Use PostgreSQL exclusion constraints to prevent double-bookings at the database level, not just in application code
- Create bookings only after Stripe webhook confirms payment to avoid blocking slots for abandoned checkouts
- Store RESEND_API_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without NEXT_PUBLIC_ prefix
- Use Server Components for booking pages and the provider dashboard to keep database queries server-side
- Use prompt queuing in V0 — queue the booking flow, slot API, checkout integration, and dashboard separately
- Use Design Mode (Option+D) to adjust the time slot grid layout, service card styling, and calendar appearance without credits
- Always store and compare times in UTC using timestamptz columns, displaying in local timezone only on the frontend
- Add email confirmations for bookings, cancellations, and reminders to reduce no-shows

## Frequently asked questions

### How does the double-booking prevention work?

A PostgreSQL exclusion constraint on the bookings table prevents overlapping time ranges for the same provider. If two customers try to book the same slot simultaneously, the database rejects the second booking regardless of application-level timing. This is more reliable than application-level checks.

### What happens if a customer abandons payment?

The booking is only created after the Stripe webhook confirms payment. If the customer abandons the Checkout page, no booking is created and the slot remains available for other customers.

### What V0 plan do I need for a booking platform?

V0 Premium is recommended because the platform requires multiple pages (booking flow, provider dashboard), API routes (slots, checkout, webhook), and integrations with Supabase, Stripe, and Resend.

### How do I handle cancellations and refunds?

Create a Server Action that updates the booking status to cancelled and calls the Stripe Refund API to issue a refund. The cancellation releases the time slot (the exclusion constraint only blocks non-cancelled bookings) so it becomes available again.

### How do I deploy the booking platform?

Click Share then Publish to Production in V0. After publishing, register the Stripe webhook URL and update the Resend from address to match your production domain.

### Can RapidDev help build a custom booking platform?

Yes. RapidDev has built 600+ apps including complex booking systems with multi-location support, resource management, and calendar integrations. Book a free consultation to discuss your specific booking requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/booking-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/booking-platform
