Build an event registration system with V0 using Next.js, Stripe for ticket payments, and Supabase for attendee management. You'll create event pages with ticket tiers, secure checkout, QR code generation for check-in, and an organizer dashboard — all in about 1-2 hours without touching a terminal.
What you're building
Event registration systems power conferences, workshops, and community gatherings. Organizers need to create events with multiple ticket tiers, manage capacity, process payments, and check in attendees — all without complex infrastructure.
V0 generates the event pages, registration forms, and organizer dashboard from prompts. Stripe handles payment processing via the Vercel Marketplace integration, and Supabase stores event data with atomic ticket reservation to prevent overselling.
The architecture uses Next.js App Router with Server Components for event listing and detail pages, API routes for Stripe Checkout and webhook handling, a PostgreSQL function for atomic ticket reservation, and client components for the interactive check-in scanner.
Final result
A complete event registration system with ticket tiers, Stripe payments, QR code confirmations, organizer check-in tools, and waitlist management.
Tech stack
Prerequisites
- A V0 account (Premium plan for Stripe integration iterations)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Stripe account (test mode — add via Vercel Marketplace in V0)
- An event to register attendees for (conference, workshop, meetup)
Build steps
Set up the database schema for events and registrations
Open V0 and create a new project. Connect Supabase and Stripe via the Connect panel and Vercel Marketplace. Create the schema for events, ticket tiers, registrations, and waitlist.
1// Paste this prompt into V0's AI chat:2// Build an event registration system. Create a Supabase schema with:3// 1. events: id (uuid PK), title (text), description (text), venue (text), start_date (timestamptz), end_date (timestamptz), banner_image_url (text), organizer_id (uuid FK to auth.users), status (text default 'draft'), max_capacity (int), created_at (timestamptz)4// 2. ticket_tiers: id (uuid PK), event_id (uuid FK to events), name (text), price_cents (int), quantity (int), sold_count (int default 0), description (text)5// 3. registrations: id (uuid PK), event_id (uuid FK to events), ticket_tier_id (uuid FK to ticket_tiers), user_id (uuid FK to auth.users), attendee_name (text), attendee_email (text), qr_code (text unique), checked_in (boolean default false), checked_in_at (timestamptz), stripe_payment_intent_id (text), created_at (timestamptz)6// 4. waitlist: id (uuid PK), event_id (uuid FK to events), email (text), created_at (timestamptz)7// Create a PostgreSQL function reserve_ticket(tier_id uuid) that does: UPDATE ticket_tiers SET sold_count = sold_count + 1 WHERE id = tier_id AND sold_count < quantity RETURNING id8// Add RLS: anyone can read events, authenticated users can register.Pro tip: The reserve_ticket function prevents overselling by atomically checking and incrementing sold_count in a single SQL statement.
Expected result: Database schema created with atomic ticket reservation function. Stripe keys auto-provisioned in the Vars tab.
Build the event listing and detail pages
Create Server Component pages for browsing events and viewing individual event details with ticket tier selection. The detail page shows capacity, pricing, and a registration form.
1import { createClient } from '@/lib/supabase/server'2import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'3import { Badge } from '@/components/ui/badge'4import { Progress } from '@/components/ui/progress'5import Link from 'next/link'67export default async function EventsPage() {8 const supabase = await createClient()910 const { data: events } = await supabase11 .from('events')12 .select('*, ticket_tiers(id, name, price_cents, quantity, sold_count)')13 .eq('status', 'published')14 .gte('end_date', new Date().toISOString())15 .order('start_date')1617 return (18 <div className="container mx-auto py-8">19 <h1 className="text-4xl font-bold mb-8">Upcoming Events</h1>20 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">21 {events?.map((event) => {22 const totalCapacity = event.ticket_tiers.reduce((s: number, t: any) => s + t.quantity, 0)23 const totalSold = event.ticket_tiers.reduce((s: number, t: any) => s + t.sold_count, 0)24 const lowestPrice = Math.min(...event.ticket_tiers.map((t: any) => t.price_cents))2526 return (27 <Link key={event.id} href={`/events/${event.id}`}>28 <Card className="overflow-hidden hover:shadow-md transition-shadow">29 {event.banner_image_url && (30 <img src={event.banner_image_url} alt={event.title} className="w-full h-48 object-cover" />31 )}32 <CardHeader>33 <CardTitle>{event.title}</CardTitle>34 <p className="text-sm text-muted-foreground">35 {new Date(event.start_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}36 </p>37 </CardHeader>38 <CardContent>39 <div className="flex items-center justify-between">40 <Badge variant="secondary">From ${(lowestPrice / 100).toFixed(2)}</Badge>41 <span className="text-sm text-muted-foreground">{totalSold}/{totalCapacity} registered</span>42 </div>43 <Progress value={(totalSold / totalCapacity) * 100} className="mt-2" />44 </CardContent>45 </Card>46 </Link>47 )48 })}49 </div>50 </div>51 )52}Expected result: The events page shows upcoming events with banner images, dates, pricing, and capacity progress bars.
Create the registration API route with atomic ticket reservation
Build the API route that reserves a ticket atomically, creates a Stripe Checkout session, and generates a unique QR code. If payment fails, the reservation is rolled back.
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'4import { randomBytes } from 'crypto'56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)7const supabase = createClient(8 process.env.SUPABASE_URL!,9 process.env.SUPABASE_SERVICE_ROLE_KEY!10)1112export async function POST(req: NextRequest) {13 const { eventId, tierId, attendeeName, attendeeEmail } = await req.json()1415 const { data: reserved } = await supabase.rpc('reserve_ticket', {16 tier_id: tierId,17 })1819 if (!reserved) {20 return NextResponse.json({ error: 'Tickets sold out' }, { status: 409 })21 }2223 const { data: tier } = await supabase24 .from('ticket_tiers')25 .select('name, price_cents, event_id')26 .eq('id', tierId)27 .single()2829 const qrCode = randomBytes(16).toString('hex')3031 const session = await stripe.checkout.sessions.create({32 payment_method_types: ['card'],33 customer_email: attendeeEmail,34 mode: 'payment',35 line_items: [{36 price_data: {37 currency: 'usd',38 unit_amount: tier!.price_cents,39 product_data: { name: `${tier!.name} ticket` },40 },41 quantity: 1,42 }],43 metadata: { eventId, tierId, attendeeName, attendeeEmail, qrCode },44 success_url: `${req.nextUrl.origin}/registration/${qrCode}`,45 cancel_url: `${req.nextUrl.origin}/events/${eventId}`,46 })4748 return NextResponse.json({ url: session.url })49}Pro tip: The reserve_ticket RPC uses WHERE sold_count < quantity in the UPDATE, so if the ticket is sold out, no row is returned and we can immediately respond with 409 Conflict.
Expected result: The registration route atomically reserves a ticket, creates Stripe Checkout, and returns the checkout URL. Sold-out tiers return a 409 error.
Handle the Stripe webhook and build the confirmation page
Create the webhook that inserts the registration record on successful payment, and build the confirmation page that displays the QR code for event check-in.
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 body = await req.text()13 const sig = req.headers.get('stripe-signature')!1415 let event: Stripe.Event16 try {17 event = stripe.webhooks.constructEvent(18 body, 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 { eventId, tierId, attendeeName, attendeeEmail, qrCode } = session.metadata ?? {}2728 await supabase.from('registrations').insert({29 event_id: eventId,30 ticket_tier_id: tierId,31 attendee_name: attendeeName,32 attendee_email: attendeeEmail,33 qr_code: qrCode,34 stripe_payment_intent_id: session.payment_intent as string,35 })36 }3738 return NextResponse.json({ received: true })39}Expected result: On successful payment, the webhook inserts the registration with the QR code. The confirmation page at /registration/[qr_code] displays the QR code for check-in.
Build the organizer dashboard with check-in scanner
Create the organizer management page with attendee list, check-in functionality using the browser camera for QR scanning, and event sales analytics.
1// Paste this prompt into V0's AI chat:2// Build an organizer dashboard at app/events/[id]/manage/page.tsx.3// Requirements:4// - Server Component that fetches event with all registrations and ticket tier stats5// - Show event summary Card: title, date, total registrations, total revenue, check-in rate6// - Attendee Table with columns: name, email, ticket tier Badge, registration date, checked-in status Badge (green checkmark or gray pending)7// - A "Check In" Button on each row that calls a Server Action to mark checked_in = true and set checked_in_at8// - A "Scan QR" Button that opens a Dialog with the browser camera (using getUserMedia API) to scan QR codes9// - The scan route at app/api/checkin/route.ts validates the qr_code, marks the registration as checked in, and returns attendee name10// - Show ticket tier breakdown: Card for each tier with name, sold/total count, revenue, and Progress bar11// - Add a "Download CSV" Button that exports the attendee list12// - Use Tabs to switch between Attendees, Analytics, and Settings viewsPro tip: Use the Vars tab to store a QR_SECRET key for HMAC-signing QR codes, preventing forgery. Validate the signature in the check-in API route.
Expected result: Organizers see all attendees, can check them in manually or via QR scan, and view sales analytics broken down by ticket tier.
Complete code
1import { NextRequest, NextResponse } from 'next/server'2import Stripe from 'stripe'3import { createClient } from '@supabase/supabase-js'4import { randomBytes } from 'crypto'56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)7const supabase = createClient(8 process.env.SUPABASE_URL!,9 process.env.SUPABASE_SERVICE_ROLE_KEY!10)1112export async function POST(req: NextRequest) {13 const { eventId, tierId, attendeeName, attendeeEmail } = await req.json()1415 const { data: reserved } = await supabase.rpc('reserve_ticket', {16 tier_id: tierId,17 })1819 if (!reserved) {20 return NextResponse.json(21 { error: 'Tickets sold out for this tier' },22 { status: 409 }23 )24 }2526 const { data: tier } = await supabase27 .from('ticket_tiers')28 .select('name, price_cents')29 .eq('id', tierId)30 .single()3132 const qrCode = randomBytes(16).toString('hex')3334 const session = await stripe.checkout.sessions.create({35 payment_method_types: ['card'],36 customer_email: attendeeEmail,37 mode: 'payment',38 line_items: [39 {40 price_data: {41 currency: 'usd',42 unit_amount: tier!.price_cents,43 product_data: { name: `${tier!.name} ticket` },44 },45 quantity: 1,46 },47 ],48 metadata: { eventId, tierId, attendeeName, attendeeEmail, qrCode },49 success_url: `${req.nextUrl.origin}/registration/${qrCode}`,50 cancel_url: `${req.nextUrl.origin}/events/${eventId}`,51 })5253 return NextResponse.json({ url: session.url })54}Customization ideas
Add promo codes for discounts
Create a promo_codes table and add a code input to the registration form. Apply percentage or fixed-amount discounts at checkout by adjusting the Stripe line item price.
Add email confirmations with calendar invite
Send a confirmation email via Resend after registration that includes an .ics calendar file attachment for easy calendar import.
Add multi-event passes
Create a pass system where attendees can purchase access to multiple events at a discounted bundle price with a single checkout.
Add live attendance analytics
Use Supabase Realtime to show a live check-in counter on the organizer dashboard, updating in real-time as attendees scan their QR codes.
Common pitfalls
Pitfall: Using a regular SELECT + UPDATE instead of atomic reservation for ticket counts
How to avoid: Use a PostgreSQL function that atomically checks AND increments in a single UPDATE WHERE clause: SET sold_count = sold_count + 1 WHERE sold_count < quantity.
Pitfall: Using request.json() in the Stripe webhook handler
How to avoid: Use await req.text() to get the raw body, then pass it to stripe.webhooks.constructEvent().
Pitfall: Generating QR codes on the client side without server validation
How to avoid: Generate QR codes server-side with cryptographic randomness and store them in the database. The check-in route validates the code exists and hasn't been used.
Best practices
- Use atomic PostgreSQL functions (RPC) for ticket reservation to prevent overselling under concurrent registrations.
- Generate QR codes server-side with cryptographic randomness (randomBytes) and store them in the database for validation.
- Use request.text() in the Stripe webhook handler for proper signature verification.
- Store ticket prices in cents as integers and display with (price_cents / 100).toFixed(2) to avoid floating-point issues.
- Use Server Components for event listing and detail pages for SEO and fast initial loads.
- Set STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and QR_SECRET in the Vars tab without NEXT_PUBLIC_ prefix.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an event registration system with Next.js and Supabase. I need to prevent overselling tickets when multiple users register simultaneously. Show me how to create a PostgreSQL function that atomically reserves a ticket by incrementing sold_count only if it's less than quantity, and how to call it from a Next.js API route via Supabase RPC.
Build the QR code check-in system for an event registration app. Create app/api/checkin/route.ts that accepts a POST with qr_code, validates it against the registrations table, marks checked_in = true with checked_in_at timestamp, and returns the attendee name. Add error handling for invalid codes and already-checked-in attendees. Build a 'use client' scanner component using getUserMedia for the browser camera.
Frequently asked questions
How do I prevent overselling tickets?
Use a PostgreSQL function (reserve_ticket) that atomically checks sold_count < quantity and increments in a single UPDATE statement. If the tier is sold out, no row is returned and the API returns a 409 Conflict error.
How does QR code check-in work?
Each registration generates a unique QR code stored in the database. The organizer scans it using the browser camera or enters it manually. The check-in API validates the code exists, hasn't been used, and marks it as checked in.
Can I offer free events without Stripe?
Yes. For free ticket tiers (price_cents = 0), skip the Stripe Checkout step and insert the registration directly. The reserve_ticket function still prevents over-capacity.
How do I deploy and set up the webhook?
Publish via V0's Share menu, then register the webhook URL (yourdomain.com/api/webhooks/stripe) in the Stripe Dashboard for checkout.session.completed events. Set STRIPE_WEBHOOK_SECRET in the Vars tab.
Can I add a waitlist for sold-out events?
Yes. When reserve_ticket returns null (sold out), offer to add the user's email to the waitlist table. When a cancellation opens a spot, notify the first waitlisted person via email.
Can RapidDev help build a custom event registration system?
Yes. RapidDev has built 600+ apps including conference management platforms with multi-track schedules, speaker management, and sponsor tiers. Book a free consultation to discuss your event needs.
What V0 plan do I need?
Premium ($20/month) is recommended for the Stripe integration and organizer dashboard. Free tier works for the basic event listing but may need manual coding for the payment flow.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation