# How to Build Online test booking with V0

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

## TL;DR

Build an exam slot booking system with V0 using Next.js, Supabase, Stripe, and shadcn/ui. Features test type selection, calendar-based slot browsing with seat capacity, Stripe payment, race-condition-safe booking via Supabase RPC, and confirmation codes — all in about 1-2 hours.

## Before you start

- A V0 account (Premium or higher recommended)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode works — connect via Vercel Marketplace)
- Test slot data prepared (test types, locations, schedule)

## Step-by-step guide

### 1. Set up the database schema for tests, slots, and bookings

Create the Supabase schema for test types, locations with capacity, time slots with seat tracking, bookings with confirmation codes, and candidate profiles.

```
// Paste this prompt into V0's AI chat:
// Build an online test booking system. Create a Supabase schema:
// 1. test_types: id (uuid PK), name (text), description (text), duration_minutes (int), price_cents (int), stripe_price_id (text)
// 2. locations: id (uuid PK), name (text), address (text), capacity (int)
// 3. test_slots: id (uuid PK), test_type_id (uuid FK to test_types), location_id (uuid FK to locations), start_time (timestamptz), end_time (timestamptz), capacity (int), booked_count (int DEFAULT 0)
// 4. bookings: id (uuid PK), user_id (uuid FK to auth.users), slot_id (uuid FK to test_slots), status (text DEFAULT 'pending' CHECK IN 'pending','confirmed','cancelled','completed'), stripe_payment_id (text), confirmation_code (text UNIQUE), booked_at (timestamptz)
// 5. candidates: user_id (uuid PK FK to auth.users), full_name (text), phone (text), id_document_type (text), id_document_number (text)
// Add RLS policies. Create an RPC function book_slot(slot_uuid, user_uuid) that atomically checks booked_count < capacity, increments booked_count, and inserts the booking with FOR UPDATE row lock.
// Generate SQL and types.
```

> Pro tip: Use V0's Stripe integration via Vercel Marketplace for instant payment setup and the Vars tab for all environment configuration in one place.

**Expected result:** All tables created with the book_slot RPC function that prevents double-booking through row-level locking.

### 2. Build the test selection and slot browsing pages

Create the booking flow where candidates select a test type, pick a location, browse available dates on a calendar, and see time slots with remaining seat counts.

```
// Paste this prompt into V0's AI chat:
// Create booking flow pages:
// 1. app/book/page.tsx — test type selection with Card grid: name, description, duration Badge, price. Location Select dropdown. 'View Available Slots' Button.
// 2. app/book/[testTypeId]/page.tsx — slot browsing: shadcn/ui Calendar for date selection. Below the calendar, show available time slots as a Card grid with start time, remaining seats Badge (green=available, yellow=few-left, red=full), and 'Book This Slot' Button. Disable full slots.
// Use Server Components for the pages. Fetch slots where booked_count < capacity for the selected date.
```

**Expected result:** Candidates can browse test types, select a location and date, and see available time slots with real-time seat counts.

### 3. Build the booking confirmation and Stripe payment

Create the booking summary page that shows selected slot details and initiates Stripe Checkout for payment, plus the webhook handler that confirms the booking atomically.

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

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

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

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody, 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 { user_id, slot_id } = session.metadata || {}

    if (user_id && slot_id) {
      const { data, error } = await supabase.rpc('book_slot', {
        slot_uuid: slot_id,
        user_uuid: user_id,
      })

      if (error) {
        console.error('Booking failed:', error.message)
        return NextResponse.json({ error: 'Slot full' }, { status: 409 })
      }

      await supabase
        .from('bookings')
        .update({
          status: 'confirmed',
          stripe_payment_id: session.payment_intent as string,
        })
        .eq('id', data)
    }
  }

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

**Expected result:** After payment, the webhook calls the book_slot RPC to atomically reserve the seat. If the slot is already full, the function raises an exception and returns a 409.

### 4. Build the booking management page and deploy

Create the page where candidates view their upcoming and past bookings, cancel reservations with refund, and see confirmation details. Then deploy.

```
// Paste this prompt into V0's AI chat:
// Create booking management:
// 1. app/my-bookings/page.tsx — two sections: Upcoming Bookings and Past Bookings.
//    - Each booking Card shows: test type name, location, date/time, confirmation code Badge, status Badge (confirmed=green, cancelled=red, completed=blue).
//    - 'Cancel Booking' Button with AlertDialog confirmation. Cancellation Server Action: refunds via Stripe, updates booking status to 'cancelled', decrements slot booked_count.
// 2. app/book/confirm/page.tsx — booking summary Card: test type, location, date, time, price. Candidate info form (name, phone, ID). 'Proceed to Payment' Button creates Stripe Checkout session with slot_id and user_id in metadata.
// Use shadcn/ui Card, Badge, AlertDialog, Button, Input, Separator.
// Use revalidatePath in the webhook handler to update slot availability.
```

> Pro tip: Use revalidatePath('/book/[testTypeId]') in the webhook handler so the slot availability page updates immediately after each booking without waiting for ISR.

**Expected result:** Candidates can view, manage, and cancel bookings. Cancellations trigger Stripe refunds and restore slot capacity. The app is deployed.

## Complete code example

File: `app/api/webhooks/stripe/route.ts`

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

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

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

  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(
      rawBody,
      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 || {}

    if (meta.user_id && meta.slot_id) {
      const { data: bookingId, error } = await supabase.rpc(
        'book_slot',
        { slot_uuid: meta.slot_id, user_uuid: meta.user_id }
      )

      if (error) {
        console.error('Booking RPC failed:', error.message)
        return NextResponse.json(
          { error: 'Slot capacity exceeded' },
          { status: 409 }
        )
      }

      await supabase
        .from('bookings')
        .update({
          status: 'confirmed',
          stripe_payment_id: session.payment_intent as string,
        })
        .eq('id', bookingId)
    }
  }

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

## Common mistakes

- **Checking capacity and inserting the booking in separate queries** — Between the check and the insert, another user can book the same slot, exceeding capacity. This race condition is common under concurrent traffic. Fix: Use a Supabase RPC function that checks capacity and inserts the booking in a single transaction with SELECT ... FOR UPDATE to lock the row during the check.
- **Using request.json() for the Stripe webhook body** — Stripe signature verification requires the raw request body. Parsing it as JSON changes the format and verification fails every time. Fix: Use request.text() to get the raw body and pass it directly to stripe.webhooks.constructEvent().
- **Not restoring slot capacity on cancellation** — If you cancel a booking without decrementing booked_count, the slot shows fewer available seats than reality. Over time, phantom bookings block real candidates. Fix: In the cancellation Server Action, decrement test_slots.booked_count along with updating the booking status. Use a Supabase RPC for atomicity.

## Best practices

- Use a Supabase RPC function with FOR UPDATE row locking to prevent double-booking race conditions
- Always use request.text() for Stripe webhook body — never request.json()
- Pass slot_id and user_id in Stripe Checkout session metadata for the webhook to identify the booking
- Use revalidatePath in the webhook handler to update slot availability pages immediately
- Add a unique constraint on confirmation_code for reliable lookup
- Use V0's Design Mode (Option+D) to adjust Calendar and slot Card styling without spending credits
- Show visual seat availability with color-coded Badges: green (available), yellow (few-left), red (full)
- Set STRIPE_WEBHOOK_SECRET in Vars tab — never hard-code webhook secrets

## Frequently asked questions

### How does the system prevent double-booking?

The book_slot RPC function uses PostgreSQL's SELECT ... FOR UPDATE to lock the slot row, checks that booked_count is less than capacity, increments the count, and inserts the booking — all in a single atomic transaction. If two users book simultaneously, one gets the lock first and the other waits, preventing overselling.

### What happens if a slot fills up during checkout?

The booking is only confirmed after Stripe payment succeeds and the webhook calls the RPC function. If the slot filled up between page load and payment completion, the RPC raises an exception. You should handle this by refunding the payment and showing the user a 'slot no longer available' message.

### Can candidates cancel and get a refund?

Yes. The cancellation Server Action calls the Stripe Refunds API, updates the booking status to cancelled, and decrements the slot's booked_count to free up the seat for other candidates.

### Do I need a paid V0 plan?

Premium ($20/month) is recommended. The booking system has multiple pages and a Stripe integration that benefit from more credits, though the core flow can be built with careful prompting on the free tier.

### How do I generate confirmation codes?

The book_slot RPC function generates a unique confirmation code using PostgreSQL's encode(gen_random_bytes(4), 'hex') which produces an 8-character alphanumeric code. The UNIQUE constraint on confirmation_code ensures no duplicates.

### How do I deploy the booking system?

Click Share in V0, then Publish to Production. Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY in the Vars tab. Register the webhook URL in Stripe Dashboard for checkout.session.completed events.

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

Yes. RapidDev has built over 600 apps including appointment and test booking platforms with multi-location support, automated reminders, and waitlist management. Book a free consultation to discuss your requirements.

---

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