# How to Build Event registration system with V0

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

## TL;DR

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.

## Before you start

- 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)

## Step-by-step guide

### 1. 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.

```
// Paste this prompt into V0's AI chat:
// Build an event registration system. Create a Supabase schema with:
// 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)
// 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)
// 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)
// 4. waitlist: id (uuid PK), event_id (uuid FK to events), email (text), created_at (timestamptz)
// 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 id
// 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.

### 2. 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.

```
import { createClient } from '@/lib/supabase/server'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import Link from 'next/link'

export default async function EventsPage() {
  const supabase = await createClient()

  const { data: events } = await supabase
    .from('events')
    .select('*, ticket_tiers(id, name, price_cents, quantity, sold_count)')
    .eq('status', 'published')
    .gte('end_date', new Date().toISOString())
    .order('start_date')

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-4xl font-bold mb-8">Upcoming Events</h1>
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {events?.map((event) => {
          const totalCapacity = event.ticket_tiers.reduce((s: number, t: any) => s + t.quantity, 0)
          const totalSold = event.ticket_tiers.reduce((s: number, t: any) => s + t.sold_count, 0)
          const lowestPrice = Math.min(...event.ticket_tiers.map((t: any) => t.price_cents))

          return (
            <Link key={event.id} href={`/events/${event.id}`}>
              <Card className="overflow-hidden hover:shadow-md transition-shadow">
                {event.banner_image_url && (
                  <img src={event.banner_image_url} alt={event.title} className="w-full h-48 object-cover" />
                )}
                <CardHeader>
                  <CardTitle>{event.title}</CardTitle>
                  <p className="text-sm text-muted-foreground">
                    {new Date(event.start_date).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
                  </p>
                </CardHeader>
                <CardContent>
                  <div className="flex items-center justify-between">
                    <Badge variant="secondary">From ${(lowestPrice / 100).toFixed(2)}</Badge>
                    <span className="text-sm text-muted-foreground">{totalSold}/{totalCapacity} registered</span>
                  </div>
                  <Progress value={(totalSold / totalCapacity) * 100} className="mt-2" />
                </CardContent>
              </Card>
            </Link>
          )
        })}
      </div>
    </div>
  )
}
```

**Expected result:** The events page shows upcoming events with banner images, dates, pricing, and capacity progress bars.

### 3. 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.

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

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 { eventId, tierId, attendeeName, attendeeEmail } = await req.json()

  const { data: reserved } = await supabase.rpc('reserve_ticket', {
    tier_id: tierId,
  })

  if (!reserved) {
    return NextResponse.json({ error: 'Tickets sold out' }, { status: 409 })
  }

  const { data: tier } = await supabase
    .from('ticket_tiers')
    .select('name, price_cents, event_id')
    .eq('id', tierId)
    .single()

  const qrCode = randomBytes(16).toString('hex')

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    customer_email: attendeeEmail,
    mode: 'payment',
    line_items: [{
      price_data: {
        currency: 'usd',
        unit_amount: tier!.price_cents,
        product_data: { name: `${tier!.name} ticket` },
      },
      quantity: 1,
    }],
    metadata: { eventId, tierId, attendeeName, attendeeEmail, qrCode },
    success_url: `${req.nextUrl.origin}/registration/${qrCode}`,
    cancel_url: `${req.nextUrl.origin}/events/${eventId}`,
  })

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

> 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.

### 4. 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.

```
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 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 { eventId, tierId, attendeeName, attendeeEmail, qrCode } = session.metadata ?? {}

    await supabase.from('registrations').insert({
      event_id: eventId,
      ticket_tier_id: tierId,
      attendee_name: attendeeName,
      attendee_email: attendeeEmail,
      qr_code: qrCode,
      stripe_payment_intent_id: session.payment_intent as string,
    })
  }

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

**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.

### 5. 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.

```
// Paste this prompt into V0's AI chat:
// Build an organizer dashboard at app/events/[id]/manage/page.tsx.
// Requirements:
// - Server Component that fetches event with all registrations and ticket tier stats
// - Show event summary Card: title, date, total registrations, total revenue, check-in rate
// - Attendee Table with columns: name, email, ticket tier Badge, registration date, checked-in status Badge (green checkmark or gray pending)
// - A "Check In" Button on each row that calls a Server Action to mark checked_in = true and set checked_in_at
// - A "Scan QR" Button that opens a Dialog with the browser camera (using getUserMedia API) to scan QR codes
// - The scan route at app/api/checkin/route.ts validates the qr_code, marks the registration as checked in, and returns attendee name
// - Show ticket tier breakdown: Card for each tier with name, sold/total count, revenue, and Progress bar
// - Add a "Download CSV" Button that exports the attendee list
// - Use Tabs to switch between Attendees, Analytics, and Settings views
```

> Pro 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 example

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

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

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 { eventId, tierId, attendeeName, attendeeEmail } = await req.json()

  const { data: reserved } = await supabase.rpc('reserve_ticket', {
    tier_id: tierId,
  })

  if (!reserved) {
    return NextResponse.json(
      { error: 'Tickets sold out for this tier' },
      { status: 409 }
    )
  }

  const { data: tier } = await supabase
    .from('ticket_tiers')
    .select('name, price_cents')
    .eq('id', tierId)
    .single()

  const qrCode = randomBytes(16).toString('hex')

  const session = await stripe.checkout.sessions.create({
    payment_method_types: ['card'],
    customer_email: attendeeEmail,
    mode: 'payment',
    line_items: [
      {
        price_data: {
          currency: 'usd',
          unit_amount: tier!.price_cents,
          product_data: { name: `${tier!.name} ticket` },
        },
        quantity: 1,
      },
    ],
    metadata: { eventId, tierId, attendeeName, attendeeEmail, qrCode },
    success_url: `${req.nextUrl.origin}/registration/${qrCode}`,
    cancel_url: `${req.nextUrl.origin}/events/${eventId}`,
  })

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

## Common mistakes

- **Using a regular SELECT + UPDATE instead of atomic reservation for ticket counts** — When multiple users register simultaneously, the read-then-write pattern allows sold_count to exceed quantity, overselling tickets. Fix: 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.
- **Using request.json() in the Stripe webhook handler** — Stripe signature verification requires the raw request body. Parsing as JSON first changes the body and causes signature mismatch. Fix: Use await req.text() to get the raw body, then pass it to stripe.webhooks.constructEvent().
- **Generating QR codes on the client side without server validation** — Client-generated QR codes can be forged. Anyone could create a valid-looking QR code without paying. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/event-registration-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/event-registration-system
