# How to Build a Conference Management System with V0

- Tool: v0
- Difficulty: Advanced
- Compatibility: V0 Premium recommended, Supabase free tier, Stripe account
- Last updated: April 2026

## TL;DR

Build a conference management system with multi-track schedule grids, tiered ticket sales through Stripe, QR code check-in, and speaker profiles. V0 generates the Next.js App Router pages while Supabase enforces non-overlapping sessions per track using exclusion constraints with tstzrange and GiST indexes.

## Before you start

- A V0 account (Premium recommended for multi-file generation)
- A Supabase project with btree_gist extension enabled
- A Stripe account with Checkout enabled
- Basic familiarity with Next.js App Router and TypeScript

## Step-by-step guide

### 1. Create the Supabase Schema with Exclusion Constraints

*Exclusion constraints with tstzrange prevent scheduling two sessions in the same track at overlapping times at the database level, making it impossible to create conflicting schedules regardless of application bugs.*

Set up the full conference schema with tracks, sessions, speakers, tickets, and registrations. Enable the btree_gist extension required for exclusion constraints, then create the constraint that enforces non-overlapping sessions per track.

```
-- Enable required extension
create extension if not exists btree_gist;

-- Conferences
create table conferences (
  id uuid primary key default gen_random_uuid(),
  organizer_id uuid references auth.users not null,
  name text not null,
  slug text unique not null,
  description text,
  venue jsonb default '{}',
  starts_at timestamptz not null,
  ends_at timestamptz not null,
  status text default 'draft',
  created_at timestamptz default now()
);

-- Tracks
create table tracks (
  id uuid primary key default gen_random_uuid(),
  conference_id uuid references conferences on delete cascade not null,
  name text not null,
  color text default '#3B82F6',
  sort_order int default 0
);

-- Sessions with exclusion constraint
create table sessions (
  id uuid primary key default gen_random_uuid(),
  track_id uuid references tracks on delete cascade not null,
  conference_id uuid references conferences on delete cascade not null,
  title text not null,
  description text,
  starts_at timestamptz not null,
  ends_at timestamptz not null,
  room text,
  capacity int default 100,
  registered_count int default 0,
  -- Prevent overlapping sessions in the same track
  exclude using gist (
    track_id with =,
    tstzrange(starts_at, ends_at) with &&
  )
);

-- Speakers
create table speakers (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  bio text,
  company text,
  avatar_url text,
  social_links jsonb default '{}'
);

-- Session-Speaker junction
create table session_speakers (
  session_id uuid references sessions on delete cascade,
  speaker_id uuid references speakers on delete cascade,
  primary key (session_id, speaker_id)
);

-- Tickets
create table tickets (
  id uuid primary key default gen_random_uuid(),
  conference_id uuid references conferences on delete cascade not null,
  tier text not null,
  price numeric not null,
  capacity int not null,
  sold_count int default 0,
  stripe_price_id text
);

-- Registrations
create table registrations (
  id uuid primary key default gen_random_uuid(),
  conference_id uuid references conferences not null,
  user_id uuid references auth.users not null,
  ticket_id uuid references tickets not null,
  stripe_payment_intent_id text,
  qr_code text unique,
  checked_in boolean default false,
  checked_in_at timestamptz,
  created_at timestamptz default now()
);

-- Session RSVPs
create table session_registrations (
  registration_id uuid references registrations on delete cascade,
  session_id uuid references sessions on delete cascade,
  primary key (registration_id, session_id)
);

-- RLS
alter table conferences enable row level security;
alter table tracks enable row level security;
alter table sessions enable row level security;
alter table speakers enable row level security;
alter table session_speakers enable row level security;
alter table tickets enable row level security;
alter table registrations enable row level security;
alter table session_registrations enable row level security;

-- Public read for conference content
create policy "Public read conferences" on conferences for select using (true);
create policy "Public read tracks" on tracks for select using (true);
create policy "Public read sessions" on sessions for select using (true);
create policy "Public read speakers" on speakers for select using (true);
create policy "Public read session_speakers" on session_speakers for select using (true);
create policy "Public read tickets" on tickets for select using (true);
create policy "Users read own registrations" on registrations for select using (auth.uid() = user_id);
create policy "Users read own session regs" on session_registrations for select
  using (registration_id in (select id from registrations where user_id = auth.uid()));
```

**Expected result:** Database schema created with exclusion constraint preventing overlapping sessions per track. All tables have RLS enabled with public read for conference content.

### 2. Build the Multi-Track Schedule Grid

*The schedule grid is the centerpiece of any conference app. Positioning sessions as absolute blocks within a CSS Grid timeline gives attendees a clear visual of what is happening across all tracks simultaneously.*

Create the public schedule page that renders tracks as columns and time as the vertical axis. Each session is positioned using calculated top and height values based on its start and end times relative to the conference day. Use prompt queuing in V0 to generate the grid layout first, then add the session detail dialog.

```
// app/conference/[slug]/schedule/page.tsx
import { createClient } from '@/lib/supabase/server';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';

const HOUR_HEIGHT = 80; // pixels per hour
const START_HOUR = 8;   // 8 AM
const END_HOUR = 18;    // 6 PM

function getPosition(startTime: string, endTime: string, dayStart: Date) {
  const start = new Date(startTime);
  const end = new Date(endTime);
  const startOffset = (start.getTime() - dayStart.getTime()) / (1000 * 60 * 60);
  const duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
  return {
    top: (startOffset - START_HOUR) * HOUR_HEIGHT,
    height: duration * HOUR_HEIGHT,
  };
}

export default async function SchedulePage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const supabase = await createClient();

  const { data: conference } = await supabase
    .from('conferences')
    .select('*')
    .eq('slug', slug)
    .single();

  if (!conference) return <div>Conference not found</div>;

  const { data: tracks } = await supabase
    .from('tracks')
    .select('*')
    .eq('conference_id', conference.id)
    .order('sort_order');

  const { data: sessions } = await supabase
    .from('sessions')
    .select('*, session_speakers(speakers(*))')
    .eq('conference_id', conference.id);

  const allTracks = tracks ?? [];
  const allSessions = sessions ?? [];
  const dayStart = new Date(conference.starts_at);
  dayStart.setHours(0, 0, 0, 0);
  const totalHeight = (END_HOUR - START_HOUR) * HOUR_HEIGHT;

  // Generate time labels
  const hours = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i);

  return (
    <div className="max-w-7xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-6">Schedule</h1>

      <div className="flex gap-4 overflow-x-auto">
        {/* Time column */}
        <div className="w-16 flex-shrink-0 relative" style={{ height: totalHeight }}>
          {hours.map((hour) => (
            <div
              key={hour}
              className="absolute text-xs text-muted-foreground"
              style={{ top: (hour - START_HOUR) * HOUR_HEIGHT }}
            >
              {hour > 12 ? `${hour - 12} PM` : hour === 12 ? '12 PM' : `${hour} AM`}
            </div>
          ))}
        </div>

        {/* Track columns */}
        {allTracks.map((track) => {
          const trackSessions = allSessions.filter(s => s.track_id === track.id);
          return (
            <div key={track.id} className="flex-1 min-w-[200px]">
              <div className="text-center font-semibold mb-2 pb-2 border-b">
                <Badge style={{ backgroundColor: track.color, color: '#fff' }}>
                  {track.name}
                </Badge>
              </div>
              <div className="relative" style={{ height: totalHeight }}>
                {/* Hour grid lines */}
                {hours.map((hour) => (
                  <div
                    key={hour}
                    className="absolute w-full border-t border-dashed border-muted"
                    style={{ top: (hour - START_HOUR) * HOUR_HEIGHT }}
                  />
                ))}
                {/* Session blocks */}
                {trackSessions.map((session) => {
                  const pos = getPosition(session.starts_at, session.ends_at, dayStart);
                  const speakers = session.session_speakers?.map((ss: any) => ss.speakers) ?? [];
                  return (
                    <Dialog key={session.id}>
                      <DialogTrigger asChild>
                        <button
                          className="absolute left-1 right-1 rounded-md p-2 text-left text-xs text-white overflow-hidden hover:opacity-90 transition-opacity"
                          style={{
                            top: pos.top,
                            height: pos.height,
                            backgroundColor: track.color,
                          }}
                        >
                          <div className="font-semibold truncate">{session.title}</div>
                          <div className="opacity-75">{session.room}</div>
                        </button>
                      </DialogTrigger>
                      <DialogContent>
                        <DialogHeader>
                          <DialogTitle>{session.title}</DialogTitle>
                        </DialogHeader>
                        <p className="text-sm text-muted-foreground">{session.description}</p>
                        <div className="flex items-center gap-2 mt-2">
                          <Badge variant="outline">{session.room}</Badge>
                          <Badge variant="secondary">
                            {session.registered_count}/{session.capacity} registered
                          </Badge>
                        </div>
                        {speakers.length > 0 && (
                          <div className="mt-4 space-y-2">
                            <h4 className="font-semibold text-sm">Speakers</h4>
                            {speakers.map((speaker: any) => (
                              <div key={speaker.id} className="flex items-center gap-3">
                                <Avatar>
                                  <AvatarImage src={speaker.avatar_url} />
                                  <AvatarFallback>{speaker.name.slice(0, 2)}</AvatarFallback>
                                </Avatar>
                                <div>
                                  <p className="font-medium text-sm">{speaker.name}</p>
                                  <p className="text-xs text-muted-foreground">{speaker.company}</p>
                                </div>
                              </div>
                            ))}
                          </div>
                        )}
                      </DialogContent>
                    </Dialog>
                  );
                })}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

**Expected result:** A visual schedule grid with tracks as columns and sessions positioned as colored blocks based on their time. Clicking a session opens a dialog with details, speakers, and capacity.

### 3. Implement Ticket Sales with Stripe Checkout

*Stripe Checkout handles the entire payment flow on a hosted page, reducing PCI compliance burden. The webhook creates the registration and generates a QR code only after payment is confirmed.*

Create the registration flow with a ticket tier selector that redirects to Stripe Checkout. The Stripe webhook on checkout.session.completed inserts the registration and generates a unique QR code for check-in.

```
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import Stripe from 'stripe';

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

export async function POST(request: NextRequest) {
  const { ticketId, conferenceId } = await request.json();
  const supabase = await createClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { data: ticket } = await supabase
    .from('tickets')
    .select('*')
    .eq('id', ticketId)
    .single();

  if (!ticket || ticket.sold_count >= ticket.capacity) {
    return NextResponse.json({ error: 'Sold out' }, { status: 400 });
  }

  const session = await stripe.checkout.sessions.create({
    mode: 'payment',
    line_items: [{
      price: ticket.stripe_price_id,
      quantity: 1,
    }],
    metadata: {
      user_id: user.id,
      conference_id: conferenceId,
      ticket_id: ticketId,
    },
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/conference/${conferenceId}/confirmation`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/conference/${conferenceId}/register`,
  });

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

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';
import { createClient } from '@supabase/supabase-js';
import QRCode from 'qrcode';

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

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

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body, sig, process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    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, conference_id, ticket_id } = session.metadata!;

    // Use service role for webhook handler
    const supabase = createClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.SUPABASE_SERVICE_ROLE_KEY!
    );

    // Generate unique QR code
    const qrCode = `REG-${crypto.randomUUID().slice(0, 8).toUpperCase()}`;

    // Create registration
    await supabase.from('registrations').insert({
      conference_id,
      user_id,
      ticket_id,
      stripe_payment_intent_id: session.payment_intent as string,
      qr_code: qrCode,
    });

    // Increment sold count
    await supabase.rpc('increment_ticket_sold', { p_ticket_id: ticket_id });
  }

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

**Expected result:** Clicking a ticket tier creates a Stripe Checkout session. After payment, the webhook creates the registration with a unique QR code and increments the ticket sold count.

### 4. Build the QR Code Check-In Scanner

*QR code check-in lets event staff quickly process attendees at the door without manual name lookups. The check-in page queries the registration by QR code and updates the checked_in status in real time.*

Create a check-in page with a text input for manual QR code entry (or camera scanner integration). When a code is scanned, it queries registrations and marks the attendee as checked in with a timestamp.

```
// app/checkin/page.tsx
'use client';

import { useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, Scan } from 'lucide-react';

type CheckinResult = {
  status: 'success' | 'already' | 'not_found';
  registration?: any;
};

export default function CheckInPage() {
  const supabase = createClient();
  const [code, setCode] = useState('');
  const [result, setResult] = useState<CheckinResult | null>(null);
  const [loading, setLoading] = useState(false);
  const [checkedCount, setCheckedCount] = useState(0);

  async function handleCheckin() {
    if (!code.trim()) return;
    setLoading(true);
    setResult(null);

    const { data: registration } = await supabase
      .from('registrations')
      .select('*, tickets(tier), conferences(name)')
      .eq('qr_code', code.trim().toUpperCase())
      .single();

    if (!registration) {
      setResult({ status: 'not_found' });
      setLoading(false);
      return;
    }

    if (registration.checked_in) {
      setResult({ status: 'already', registration });
      setLoading(false);
      return;
    }

    await supabase
      .from('registrations')
      .update({ checked_in: true, checked_in_at: new Date().toISOString() })
      .eq('id', registration.id);

    setCheckedCount(prev => prev + 1);
    setResult({ status: 'success', registration });
    setCode('');
    setLoading(false);
  }

  return (
    <div className="max-w-md mx-auto p-6 space-y-6">
      <div className="text-center">
        <Scan className="h-12 w-12 mx-auto text-primary" />
        <h1 className="text-2xl font-bold mt-2">Check-In</h1>
        <p className="text-muted-foreground">Scan or enter QR code</p>
        <Badge className="mt-2">{checkedCount} checked in this session</Badge>
      </div>

      <div className="flex gap-2">
        <Input
          value={code}
          onChange={e => setCode(e.target.value)}
          placeholder="Enter QR code (e.g., REG-A1B2C3D4)"
          onKeyDown={e => e.key === 'Enter' && handleCheckin()}
          autoFocus
        />
        <Button onClick={handleCheckin} disabled={loading}>
          {loading ? 'Checking...' : 'Check In'}
        </Button>
      </div>

      {result?.status === 'success' && (
        <Card className="border-green-500">
          <CardContent className="pt-6 text-center">
            <CheckCircle className="h-12 w-12 text-green-500 mx-auto" />
            <p className="text-lg font-semibold mt-2">Checked In</p>
            <Badge className="mt-1">{result.registration.tickets.tier}</Badge>
          </CardContent>
        </Card>
      )}

      {result?.status === 'already' && (
        <Card className="border-yellow-500">
          <CardContent className="pt-6 text-center">
            <XCircle className="h-12 w-12 text-yellow-500 mx-auto" />
            <p className="text-lg font-semibold mt-2">Already Checked In</p>
            <p className="text-sm text-muted-foreground">
              at {new Date(result.registration.checked_in_at).toLocaleTimeString()}
            </p>
          </CardContent>
        </Card>
      )}

      {result?.status === 'not_found' && (
        <Card className="border-red-500">
          <CardContent className="pt-6 text-center">
            <XCircle className="h-12 w-12 text-red-500 mx-auto" />
            <p className="text-lg font-semibold mt-2">Not Found</p>
            <p className="text-sm text-muted-foreground">No registration with this code</p>
          </CardContent>
        </Card>
      )}
    </div>
  );
}
```

**Expected result:** A check-in page that validates QR codes against registrations and shows success, already-checked-in, or not-found states with clear visual feedback.

### 5. Wire Up Environment Variables and Deploy

*Proper environment variable management keeps your Stripe keys secure while ensuring the webhook endpoint and Checkout redirect URLs work correctly in production.*

Set all required environment variables in V0's Vars tab. Register the production webhook URL in Stripe Dashboard. Use prompt queuing in V0 to generate the registration flow, admin dashboard, and check-in scanner as separate focused prompts.

```
// Environment variables to set in V0's Vars tab:
// STRIPE_SECRET_KEY=sk_live_...
// STRIPE_WEBHOOK_SECRET=whsec_...
// NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
// NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
// SUPABASE_SERVICE_ROLE_KEY=eyJ...
// NEXT_PUBLIC_APP_URL=https://your-app.vercel.app

// Helper: increment ticket sold count (Supabase function)
// Run in Supabase SQL editor:
//
// create or replace function increment_ticket_sold(p_ticket_id uuid)
// returns void as $$
// begin
//   update tickets set sold_count = sold_count + 1
//   where id = p_ticket_id and sold_count < capacity;
// end;
// $$ language plpgsql;

// app/conference/[slug]/register/page.tsx
import { createClient } from '@/lib/supabase/server';
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { CheckoutButton } from './checkout-button';

export default async function RegisterPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const supabase = await createClient();

  const { data: conference } = await supabase
    .from('conferences')
    .select('*')
    .eq('slug', slug)
    .single();

  if (!conference) return <div>Conference not found</div>;

  const { data: tickets } = await supabase
    .from('tickets')
    .select('*')
    .eq('conference_id', conference.id)
    .order('price');

  return (
    <div className="max-w-4xl mx-auto p-6 space-y-6">
      <h1 className="text-3xl font-bold">Register for {conference.name}</h1>
      <p className="text-muted-foreground">
        {new Date(conference.starts_at).toLocaleDateString()} -{' '}
        {new Date(conference.ends_at).toLocaleDateString()}
      </p>

      <div className="grid md:grid-cols-3 gap-4">
        {tickets?.map((ticket) => {
          const soldOut = ticket.sold_count >= ticket.capacity;
          return (
            <Card key={ticket.id} className={soldOut ? 'opacity-60' : ''}>
              <CardHeader>
                <CardTitle className="text-lg">{ticket.tier}</CardTitle>
                <p className="text-3xl font-bold">${ticket.price}</p>
              </CardHeader>
              <CardContent>
                <Badge variant={soldOut ? 'destructive' : 'secondary'}>
                  {soldOut
                    ? 'Sold Out'
                    : `${ticket.capacity - ticket.sold_count} remaining`}
                </Badge>
              </CardContent>
              <CardFooter>
                <CheckoutButton
                  ticketId={ticket.id}
                  conferenceId={conference.id}
                  disabled={soldOut}
                />
              </CardFooter>
            </Card>
          );
        })}
      </div>
    </div>
  );
}
```

**Expected result:** Registration page shows ticket tiers with pricing and availability. Clicking a tier redirects to Stripe Checkout. After payment, the webhook creates the registration with a QR code.

## Complete code example

File: `app/conference/[slug]/schedule/page.tsx`

```typescript
import { createClient } from '@/lib/supabase/server';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';

const HOUR_HEIGHT = 80;
const START_HOUR = 8;
const END_HOUR = 18;

function getPosition(startTime: string, endTime: string, dayStart: Date) {
  const start = new Date(startTime);
  const end = new Date(endTime);
  const startOffset = (start.getTime() - dayStart.getTime()) / (1000 * 60 * 60);
  const duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
  return { top: (startOffset - START_HOUR) * HOUR_HEIGHT, height: duration * HOUR_HEIGHT };
}

export default async function SchedulePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const supabase = await createClient();

  const { data: conference } = await supabase.from('conferences').select('*').eq('slug', slug).single();
  if (!conference) return <div className="p-12 text-center">Conference not found</div>;

  const { data: tracks } = await supabase.from('tracks').select('*').eq('conference_id', conference.id).order('sort_order');
  const { data: sessions } = await supabase.from('sessions').select('*, session_speakers(speakers(*))').eq('conference_id', conference.id);

  const allTracks = tracks ?? [];
  const allSessions = sessions ?? [];
  const dayStart = new Date(conference.starts_at);
  dayStart.setHours(0, 0, 0, 0);
  const totalHeight = (END_HOUR - START_HOUR) * HOUR_HEIGHT;
  const hours = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i);

  return (
    <div className="max-w-7xl mx-auto p-6">
      <h1 className="text-3xl font-bold mb-2">{conference.name}</h1>
      <p className="text-muted-foreground mb-6">{new Date(conference.starts_at).toLocaleDateString()}</p>

      <div className="flex gap-4 overflow-x-auto">
        <div className="w-16 flex-shrink-0 relative" style={{ height: totalHeight }}>
          {hours.map(h => (
            <div key={h} className="absolute text-xs text-muted-foreground" style={{ top: (h - START_HOUR) * HOUR_HEIGHT }}>
              {h > 12 ? `${h - 12} PM` : h === 12 ? '12 PM' : `${h} AM`}
            </div>
          ))}
        </div>

        {allTracks.map(track => {
          const trackSessions = allSessions.filter(s => s.track_id === track.id);
          return (
            <div key={track.id} className="flex-1 min-w-[200px]">
              <div className="text-center font-semibold mb-2 pb-2 border-b">
                <Badge style={{ backgroundColor: track.color, color: '#fff' }}>{track.name}</Badge>
              </div>
              <div className="relative" style={{ height: totalHeight }}>
                {hours.map(h => (
                  <div key={h} className="absolute w-full border-t border-dashed border-muted" style={{ top: (h - START_HOUR) * HOUR_HEIGHT }} />
                ))}
                {trackSessions.map(session => {
                  const pos = getPosition(session.starts_at, session.ends_at, dayStart);
                  const speakers = session.session_speakers?.map((ss: any) => ss.speakers) ?? [];
                  return (
                    <Dialog key={session.id}>
                      <DialogTrigger asChild>
                        <button className="absolute left-1 right-1 rounded-md p-2 text-left text-xs text-white overflow-hidden hover:opacity-90 transition-opacity"
                          style={{ top: pos.top, height: pos.height, backgroundColor: track.color }}>
                          <div className="font-semibold truncate">{session.title}</div>
                          <div className="opacity-75">{session.room}</div>
                        </button>
                      </DialogTrigger>
                      <DialogContent>
                        <DialogHeader><DialogTitle>{session.title}</DialogTitle></DialogHeader>
                        <p className="text-sm text-muted-foreground">{session.description}</p>
                        <div className="flex gap-2 mt-2">
                          <Badge variant="outline">{session.room}</Badge>
                          <Badge variant="secondary">{session.registered_count}/{session.capacity}</Badge>
                        </div>
                        {speakers.length > 0 && (
                          <div className="mt-4 space-y-2">
                            <h4 className="font-semibold text-sm">Speakers</h4>
                            {speakers.map((s: any) => (
                              <div key={s.id} className="flex items-center gap-3">
                                <Avatar><AvatarImage src={s.avatar_url} /><AvatarFallback>{s.name.slice(0, 2)}</AvatarFallback></Avatar>
                                <div><p className="font-medium text-sm">{s.name}</p><p className="text-xs text-muted-foreground">{s.company}</p></div>
                              </div>
                            ))}
                          </div>
                        )}
                      </DialogContent>
                    </Dialog>
                  );
                })}
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}
```

## Common mistakes

- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined
- **undefined** — undefined Fix: undefined

## Best practices

- Use PostgreSQL exclusion constraints with tstzrange to prevent overlapping sessions per track at the database level
- Always use request.text() for Stripe webhook body parsing to preserve the raw body for signature verification
- Generate QR codes only after successful payment via the webhook, never during the initial checkout flow
- Store Stripe keys in V0's Vars tab without the NEXT_PUBLIC_ prefix since they are used server-side only
- Use prompt queuing in V0 to generate the schedule grid, registration flow, admin dashboard, and check-in page as separate focused prompts
- Create a Supabase function for incrementing ticket sold_count that checks capacity to prevent overselling
- Render the schedule as a Server Component for SEO and fast initial load

## Frequently asked questions

### What is an exclusion constraint and how does it prevent schedule conflicts?

An exclusion constraint tells PostgreSQL to reject any INSERT or UPDATE that would create overlapping values. The constraint EXCLUDE USING gist (track_id WITH =, tstzrange(starts_at, ends_at) WITH &&) means: no two rows can have the same track_id AND overlapping time ranges. This enforces conflict-free scheduling at the database level regardless of application logic.

### Why do I need the btree_gist extension?

PostgreSQL's default GiST index only supports certain data types. The btree_gist extension adds GiST support for scalar types like uuid, which is needed because the exclusion constraint combines a uuid column (track_id with =) with a range type (tstzrange with &&). Without btree_gist, PostgreSQL cannot create the GiST index needed for the constraint.

### How are sessions positioned in the schedule grid?

Each session's position is calculated from its start and end timestamps relative to the day start. The top offset is (start_hour - 8) * 80 pixels, and the height is (duration_in_hours) * 80 pixels. Sessions are rendered with position: absolute inside a relative container whose total height covers the full day (8 AM to 6 PM = 800px).

### How does QR code check-in work?

When payment completes, the Stripe webhook generates a unique QR code string (e.g., REG-A1B2C3D4) and stores it in the registrations table. At the event, staff open the check-in page, scan or type the code, and the app queries registrations by qr_code. If found and not already checked in, it updates checked_in to true with a timestamp.

### How do I prevent overselling tickets?

The increment_ticket_sold Supabase function checks sold_count < capacity before incrementing. It runs inside the webhook handler after payment is confirmed. Additionally, the checkout route checks availability before creating the Stripe session. For high-traffic events, wrap the increment in a SELECT FOR UPDATE to serialize concurrent updates.

### Where should I store the Stripe API keys?

Store STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in V0's Vars tab without the NEXT_PUBLIC_ prefix. These are server-side only secrets used in API routes. After deploying, register the production webhook URL (https://your-app.vercel.app/api/webhooks/stripe) in Stripe Dashboard and copy the webhook signing secret to Vars.

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

Yes. RapidDev has helped over 600 businesses build event management platforms with features like multi-day schedules, speaker submission portals, live audience polling, and attendee networking. Book a free consultation at RapidDev to scope your conference system.

---

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