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.
What you're building
Testing centers, certification bodies, and driving schools need an online booking system where candidates select a test type, pick an available date and time with seat limits, and pay for their reservation.
V0 generates the booking flow UI — date picker, slot grid, and payment confirmation — from prompts. Stripe via the Vercel Marketplace handles payments, and Supabase stores test types, slots, and bookings with atomic capacity management.
The architecture uses Server Components for the slot browsing pages, a Supabase RPC function for race-condition-safe booking, Stripe Checkout for payment, and a webhook handler that confirms the booking and decrements available capacity in a single transaction.
Final result
An online test booking system with test type selection, calendar slot browsing, seat capacity tracking, Stripe payment, and booking management with cancellation support.
Tech stack
Prerequisites
- 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)
Build steps
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.
1// Paste this prompt into V0's AI chat:2// Build an online test booking system. Create a Supabase schema:3// 1. test_types: id (uuid PK), name (text), description (text), duration_minutes (int), price_cents (int), stripe_price_id (text)4// 2. locations: id (uuid PK), name (text), address (text), capacity (int)5// 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)6// 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)7// 5. candidates: user_id (uuid PK FK to auth.users), full_name (text), phone (text), id_document_type (text), id_document_number (text)8// 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.9// 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.
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.
1// Paste this prompt into V0's AI chat:2// Create booking flow pages:3// 1. app/book/page.tsx — test type selection with Card grid: name, description, duration Badge, price. Location Select dropdown. 'View Available Slots' Button.4// 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.5// 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(18 rawBody, sig, process.env.STRIPE_WEBHOOK_SECRET!19 )20 } catch {21 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })22 }2324 if (event.type === 'checkout.session.completed') {25 const session = event.data.object as Stripe.Checkout.Session26 const { user_id, slot_id } = session.metadata || {}2728 if (user_id && slot_id) {29 const { data, error } = await supabase.rpc('book_slot', {30 slot_uuid: slot_id,31 user_uuid: user_id,32 })3334 if (error) {35 console.error('Booking failed:', error.message)36 return NextResponse.json({ error: 'Slot full' }, { status: 409 })37 }3839 await supabase40 .from('bookings')41 .update({42 status: 'confirmed',43 stripe_payment_id: session.payment_intent as string,44 })45 .eq('id', data)46 }47 }4849 return NextResponse.json({ received: true })50}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.
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.
1// Paste this prompt into V0's AI chat:2// Create booking management:3// 1. app/my-bookings/page.tsx — two sections: Upcoming Bookings and Past Bookings.4// - Each booking Card shows: test type name, location, date/time, confirmation code Badge, status Badge (confirmed=green, cancelled=red, completed=blue).5// - 'Cancel Booking' Button with AlertDialog confirmation. Cancellation Server Action: refunds via Stripe, updates booking status to 'cancelled', decrements slot booked_count.6// 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.7// Use shadcn/ui Card, Badge, AlertDialog, Button, Input, Separator.8// 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
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'45const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)6const supabase = createClient(7 process.env.SUPABASE_URL!,8 process.env.SUPABASE_SERVICE_ROLE_KEY!9)1011export async function POST(req: NextRequest) {12 const rawBody = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(18 rawBody,19 sig,20 process.env.STRIPE_WEBHOOK_SECRET!21 )22 } catch {23 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })24 }2526 if (event.type === 'checkout.session.completed') {27 const session = event.data.object as Stripe.Checkout.Session28 const meta = session.metadata || {}2930 if (meta.user_id && meta.slot_id) {31 const { data: bookingId, error } = await supabase.rpc(32 'book_slot',33 { slot_uuid: meta.slot_id, user_uuid: meta.user_id }34 )3536 if (error) {37 console.error('Booking RPC failed:', error.message)38 return NextResponse.json(39 { error: 'Slot capacity exceeded' },40 { status: 409 }41 )42 }4344 await supabase45 .from('bookings')46 .update({47 status: 'confirmed',48 stripe_payment_id: session.payment_intent as string,49 })50 .eq('id', bookingId)51 }52 }5354 return NextResponse.json({ received: true })55}Customization ideas
Email confirmation with QR code
Send a confirmation email via Resend with a QR code containing the confirmation code that staff can scan on test day.
Waitlist for full slots
Add a waitlist table where candidates join when a slot is full. If a cancellation occurs, automatically notify the first person on the waitlist.
Recurring slot generation
Build an admin page that generates weekly recurring slots based on a schedule template instead of creating each slot manually.
Admin capacity dashboard
Create a dashboard showing booking fill rates, popular test types, revenue per location, and cancellation rates with Recharts charts.
Common pitfalls
Pitfall: Checking capacity and inserting the booking in separate queries
How to avoid: 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.
Pitfall: Using request.json() for the Stripe webhook body
How to avoid: Use request.text() to get the raw body and pass it directly to stripe.webhooks.constructEvent().
Pitfall: Not restoring slot capacity on cancellation
How to avoid: 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
AI prompts to try
Copy these prompts to build this project faster.
I'm building a test booking system with Next.js App Router and Supabase. I need a Supabase RPC function that atomically books a test slot. It should: 1) Lock the test_slots row with FOR UPDATE, 2) Check that booked_count < capacity, 3) Increment booked_count, 4) Insert a booking record with a generated confirmation_code, 5) Return the booking ID. If the slot is full, raise an exception. Please write the PostgreSQL function and the Next.js Server Action that calls it.
Create a time slot picker component. It receives an array of slots with id, start_time, end_time, capacity, and booked_count. Render as a responsive Card grid. Each Card shows the time range and remaining seats. Use Badge with green/yellow/red color based on availability percentage. Disable and gray out full slots. On click, call onSelectSlot with the slot id. Highlight the selected slot with a ring border.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation