Skip to main content
RapidDev - Software Development Agency

How to Build a Conference Management System with V0

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.

What you'll build

  • Multi-track schedule grid with sessions rendered as positioned time blocks
  • Speaker management with bios, companies, and social links
  • Tiered ticket sales with Stripe Checkout integration
  • QR code generation at registration for day-of check-in
  • Session RSVP system with capacity tracking
  • Organizer admin dashboard for managing tracks, sessions, and attendees
  • Public conference landing page with schedule and registration flow
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced16 min read2-4 hoursV0 Premium recommended, Supabase free tier, Stripe accountLast updated April 2026RapidDev Engineering Team
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.

Quick facts about this guide
FactValue
ToolV0
Build time2-4 hours
DifficultyAdvanced
Last updatedApril 2026

Project overview

Tech stack

V0 by Vercel (AI code generation)
Next.js 14+ (App Router)
TypeScript
Tailwind CSS + shadcn/ui
Supabase (PostgreSQL + Auth)
Stripe (Checkout + Webhooks)
qrcode (QR code generation)

Prerequisites

  • 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

Build steps

1

Create the Supabase Schema with Exclusion Constraints

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.

typescript
1-- Enable required extension
2create extension if not exists btree_gist;
3
4-- Conferences
5create table conferences (
6 id uuid primary key default gen_random_uuid(),
7 organizer_id uuid references auth.users not null,
8 name text not null,
9 slug text unique not null,
10 description text,
11 venue jsonb default '{}',
12 starts_at timestamptz not null,
13 ends_at timestamptz not null,
14 status text default 'draft',
15 created_at timestamptz default now()
16);
17
18-- Tracks
19create table tracks (
20 id uuid primary key default gen_random_uuid(),
21 conference_id uuid references conferences on delete cascade not null,
22 name text not null,
23 color text default '#3B82F6',
24 sort_order int default 0
25);
26
27-- Sessions with exclusion constraint
28create table sessions (
29 id uuid primary key default gen_random_uuid(),
30 track_id uuid references tracks on delete cascade not null,
31 conference_id uuid references conferences on delete cascade not null,
32 title text not null,
33 description text,
34 starts_at timestamptz not null,
35 ends_at timestamptz not null,
36 room text,
37 capacity int default 100,
38 registered_count int default 0,
39 -- Prevent overlapping sessions in the same track
40 exclude using gist (
41 track_id with =,
42 tstzrange(starts_at, ends_at) with &&
43 )
44);
45
46-- Speakers
47create table speakers (
48 id uuid primary key default gen_random_uuid(),
49 name text not null,
50 bio text,
51 company text,
52 avatar_url text,
53 social_links jsonb default '{}'
54);
55
56-- Session-Speaker junction
57create table session_speakers (
58 session_id uuid references sessions on delete cascade,
59 speaker_id uuid references speakers on delete cascade,
60 primary key (session_id, speaker_id)
61);
62
63-- Tickets
64create table tickets (
65 id uuid primary key default gen_random_uuid(),
66 conference_id uuid references conferences on delete cascade not null,
67 tier text not null,
68 price numeric not null,
69 capacity int not null,
70 sold_count int default 0,
71 stripe_price_id text
72);
73
74-- Registrations
75create table registrations (
76 id uuid primary key default gen_random_uuid(),
77 conference_id uuid references conferences not null,
78 user_id uuid references auth.users not null,
79 ticket_id uuid references tickets not null,
80 stripe_payment_intent_id text,
81 qr_code text unique,
82 checked_in boolean default false,
83 checked_in_at timestamptz,
84 created_at timestamptz default now()
85);
86
87-- Session RSVPs
88create table session_registrations (
89 registration_id uuid references registrations on delete cascade,
90 session_id uuid references sessions on delete cascade,
91 primary key (registration_id, session_id)
92);
93
94-- RLS
95alter table conferences enable row level security;
96alter table tracks enable row level security;
97alter table sessions enable row level security;
98alter table speakers enable row level security;
99alter table session_speakers enable row level security;
100alter table tickets enable row level security;
101alter table registrations enable row level security;
102alter table session_registrations enable row level security;
103
104-- Public read for conference content
105create policy "Public read conferences" on conferences for select using (true);
106create policy "Public read tracks" on tracks for select using (true);
107create policy "Public read sessions" on sessions for select using (true);
108create policy "Public read speakers" on speakers for select using (true);
109create policy "Public read session_speakers" on session_speakers for select using (true);
110create policy "Public read tickets" on tickets for select using (true);
111create policy "Users read own registrations" on registrations for select using (auth.uid() = user_id);
112create policy "Users read own session regs" on session_registrations for select
113 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

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.

typescript
1// app/conference/[slug]/schedule/page.tsx
2import { createClient } from '@/lib/supabase/server';
3import { Card } from '@/components/ui/card';
4import { Badge } from '@/components/ui/badge';
5import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
6import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
7import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
8
9const HOUR_HEIGHT = 80; // pixels per hour
10const START_HOUR = 8; // 8 AM
11const END_HOUR = 18; // 6 PM
12
13function getPosition(startTime: string, endTime: string, dayStart: Date) {
14 const start = new Date(startTime);
15 const end = new Date(endTime);
16 const startOffset = (start.getTime() - dayStart.getTime()) / (1000 * 60 * 60);
17 const duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
18 return {
19 top: (startOffset - START_HOUR) * HOUR_HEIGHT,
20 height: duration * HOUR_HEIGHT,
21 };
22}
23
24export default async function SchedulePage({
25 params,
26}: {
27 params: Promise<{ slug: string }>;
28}) {
29 const { slug } = await params;
30 const supabase = await createClient();
31
32 const { data: conference } = await supabase
33 .from('conferences')
34 .select('*')
35 .eq('slug', slug)
36 .single();
37
38 if (!conference) return <div>Conference not found</div>;
39
40 const { data: tracks } = await supabase
41 .from('tracks')
42 .select('*')
43 .eq('conference_id', conference.id)
44 .order('sort_order');
45
46 const { data: sessions } = await supabase
47 .from('sessions')
48 .select('*, session_speakers(speakers(*))')
49 .eq('conference_id', conference.id);
50
51 const allTracks = tracks ?? [];
52 const allSessions = sessions ?? [];
53 const dayStart = new Date(conference.starts_at);
54 dayStart.setHours(0, 0, 0, 0);
55 const totalHeight = (END_HOUR - START_HOUR) * HOUR_HEIGHT;
56
57 // Generate time labels
58 const hours = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i);
59
60 return (
61 <div className="max-w-7xl mx-auto p-6">
62 <h1 className="text-3xl font-bold mb-6">Schedule</h1>
63
64 <div className="flex gap-4 overflow-x-auto">
65 {/* Time column */}
66 <div className="w-16 flex-shrink-0 relative" style={{ height: totalHeight }}>
67 {hours.map((hour) => (
68 <div
69 key={hour}
70 className="absolute text-xs text-muted-foreground"
71 style={{ top: (hour - START_HOUR) * HOUR_HEIGHT }}
72 >
73 {hour > 12 ? `${hour - 12} PM` : hour === 12 ? '12 PM' : `${hour} AM`}
74 </div>
75 ))}
76 </div>
77
78 {/* Track columns */}
79 {allTracks.map((track) => {
80 const trackSessions = allSessions.filter(s => s.track_id === track.id);
81 return (
82 <div key={track.id} className="flex-1 min-w-[200px]">
83 <div className="text-center font-semibold mb-2 pb-2 border-b">
84 <Badge style={{ backgroundColor: track.color, color: '#fff' }}>
85 {track.name}
86 </Badge>
87 </div>
88 <div className="relative" style={{ height: totalHeight }}>
89 {/* Hour grid lines */}
90 {hours.map((hour) => (
91 <div
92 key={hour}
93 className="absolute w-full border-t border-dashed border-muted"
94 style={{ top: (hour - START_HOUR) * HOUR_HEIGHT }}
95 />
96 ))}
97 {/* Session blocks */}
98 {trackSessions.map((session) => {
99 const pos = getPosition(session.starts_at, session.ends_at, dayStart);
100 const speakers = session.session_speakers?.map((ss: any) => ss.speakers) ?? [];
101 return (
102 <Dialog key={session.id}>
103 <DialogTrigger asChild>
104 <button
105 className="absolute left-1 right-1 rounded-md p-2 text-left text-xs text-white overflow-hidden hover:opacity-90 transition-opacity"
106 style={{
107 top: pos.top,
108 height: pos.height,
109 backgroundColor: track.color,
110 }}
111 >
112 <div className="font-semibold truncate">{session.title}</div>
113 <div className="opacity-75">{session.room}</div>
114 </button>
115 </DialogTrigger>
116 <DialogContent>
117 <DialogHeader>
118 <DialogTitle>{session.title}</DialogTitle>
119 </DialogHeader>
120 <p className="text-sm text-muted-foreground">{session.description}</p>
121 <div className="flex items-center gap-2 mt-2">
122 <Badge variant="outline">{session.room}</Badge>
123 <Badge variant="secondary">
124 {session.registered_count}/{session.capacity} registered
125 </Badge>
126 </div>
127 {speakers.length > 0 && (
128 <div className="mt-4 space-y-2">
129 <h4 className="font-semibold text-sm">Speakers</h4>
130 {speakers.map((speaker: any) => (
131 <div key={speaker.id} className="flex items-center gap-3">
132 <Avatar>
133 <AvatarImage src={speaker.avatar_url} />
134 <AvatarFallback>{speaker.name.slice(0, 2)}</AvatarFallback>
135 </Avatar>
136 <div>
137 <p className="font-medium text-sm">{speaker.name}</p>
138 <p className="text-xs text-muted-foreground">{speaker.company}</p>
139 </div>
140 </div>
141 ))}
142 </div>
143 )}
144 </DialogContent>
145 </Dialog>
146 );
147 })}
148 </div>
149 </div>
150 );
151 })}
152 </div>
153 </div>
154 );
155}

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

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.

typescript
1// app/api/checkout/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3import { createClient } from '@/lib/supabase/server';
4import Stripe from 'stripe';
5
6const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
7
8export async function POST(request: NextRequest) {
9 const { ticketId, conferenceId } = await request.json();
10 const supabase = await createClient();
11
12 const { data: { user } } = await supabase.auth.getUser();
13 if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
14
15 const { data: ticket } = await supabase
16 .from('tickets')
17 .select('*')
18 .eq('id', ticketId)
19 .single();
20
21 if (!ticket || ticket.sold_count >= ticket.capacity) {
22 return NextResponse.json({ error: 'Sold out' }, { status: 400 });
23 }
24
25 const session = await stripe.checkout.sessions.create({
26 mode: 'payment',
27 line_items: [{
28 price: ticket.stripe_price_id,
29 quantity: 1,
30 }],
31 metadata: {
32 user_id: user.id,
33 conference_id: conferenceId,
34 ticket_id: ticketId,
35 },
36 success_url: `${process.env.NEXT_PUBLIC_APP_URL}/conference/${conferenceId}/confirmation`,
37 cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/conference/${conferenceId}/register`,
38 });
39
40 return NextResponse.json({ url: session.url });
41}
42
43// app/api/webhooks/stripe/route.ts
44import { NextRequest, NextResponse } from 'next/server';
45import Stripe from 'stripe';
46import { createClient } from '@supabase/supabase-js';
47import QRCode from 'qrcode';
48
49const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
50
51export async function POST(request: NextRequest) {
52 const body = await request.text();
53 const sig = request.headers.get('stripe-signature')!;
54
55 let event: Stripe.Event;
56 try {
57 event = stripe.webhooks.constructEvent(
58 body, sig, process.env.STRIPE_WEBHOOK_SECRET!
59 );
60 } catch (err) {
61 return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
62 }
63
64 if (event.type === 'checkout.session.completed') {
65 const session = event.data.object as Stripe.Checkout.Session;
66 const { user_id, conference_id, ticket_id } = session.metadata!;
67
68 // Use service role for webhook handler
69 const supabase = createClient(
70 process.env.NEXT_PUBLIC_SUPABASE_URL!,
71 process.env.SUPABASE_SERVICE_ROLE_KEY!
72 );
73
74 // Generate unique QR code
75 const qrCode = `REG-${crypto.randomUUID().slice(0, 8).toUpperCase()}`;
76
77 // Create registration
78 await supabase.from('registrations').insert({
79 conference_id,
80 user_id,
81 ticket_id,
82 stripe_payment_intent_id: session.payment_intent as string,
83 qr_code: qrCode,
84 });
85
86 // Increment sold count
87 await supabase.rpc('increment_ticket_sold', { p_ticket_id: ticket_id });
88 }
89
90 return NextResponse.json({ received: true });
91}

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

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.

typescript
1// app/checkin/page.tsx
2'use client';
3
4import { useState } from 'react';
5import { createClient } from '@/lib/supabase/client';
6import { Button } from '@/components/ui/button';
7import { Input } from '@/components/ui/input';
8import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
9import { Badge } from '@/components/ui/badge';
10import { CheckCircle, XCircle, Scan } from 'lucide-react';
11
12type CheckinResult = {
13 status: 'success' | 'already' | 'not_found';
14 registration?: any;
15};
16
17export default function CheckInPage() {
18 const supabase = createClient();
19 const [code, setCode] = useState('');
20 const [result, setResult] = useState<CheckinResult | null>(null);
21 const [loading, setLoading] = useState(false);
22 const [checkedCount, setCheckedCount] = useState(0);
23
24 async function handleCheckin() {
25 if (!code.trim()) return;
26 setLoading(true);
27 setResult(null);
28
29 const { data: registration } = await supabase
30 .from('registrations')
31 .select('*, tickets(tier), conferences(name)')
32 .eq('qr_code', code.trim().toUpperCase())
33 .single();
34
35 if (!registration) {
36 setResult({ status: 'not_found' });
37 setLoading(false);
38 return;
39 }
40
41 if (registration.checked_in) {
42 setResult({ status: 'already', registration });
43 setLoading(false);
44 return;
45 }
46
47 await supabase
48 .from('registrations')
49 .update({ checked_in: true, checked_in_at: new Date().toISOString() })
50 .eq('id', registration.id);
51
52 setCheckedCount(prev => prev + 1);
53 setResult({ status: 'success', registration });
54 setCode('');
55 setLoading(false);
56 }
57
58 return (
59 <div className="max-w-md mx-auto p-6 space-y-6">
60 <div className="text-center">
61 <Scan className="h-12 w-12 mx-auto text-primary" />
62 <h1 className="text-2xl font-bold mt-2">Check-In</h1>
63 <p className="text-muted-foreground">Scan or enter QR code</p>
64 <Badge className="mt-2">{checkedCount} checked in this session</Badge>
65 </div>
66
67 <div className="flex gap-2">
68 <Input
69 value={code}
70 onChange={e => setCode(e.target.value)}
71 placeholder="Enter QR code (e.g., REG-A1B2C3D4)"
72 onKeyDown={e => e.key === 'Enter' && handleCheckin()}
73 autoFocus
74 />
75 <Button onClick={handleCheckin} disabled={loading}>
76 {loading ? 'Checking...' : 'Check In'}
77 </Button>
78 </div>
79
80 {result?.status === 'success' && (
81 <Card className="border-green-500">
82 <CardContent className="pt-6 text-center">
83 <CheckCircle className="h-12 w-12 text-green-500 mx-auto" />
84 <p className="text-lg font-semibold mt-2">Checked In</p>
85 <Badge className="mt-1">{result.registration.tickets.tier}</Badge>
86 </CardContent>
87 </Card>
88 )}
89
90 {result?.status === 'already' && (
91 <Card className="border-yellow-500">
92 <CardContent className="pt-6 text-center">
93 <XCircle className="h-12 w-12 text-yellow-500 mx-auto" />
94 <p className="text-lg font-semibold mt-2">Already Checked In</p>
95 <p className="text-sm text-muted-foreground">
96 at {new Date(result.registration.checked_in_at).toLocaleTimeString()}
97 </p>
98 </CardContent>
99 </Card>
100 )}
101
102 {result?.status === 'not_found' && (
103 <Card className="border-red-500">
104 <CardContent className="pt-6 text-center">
105 <XCircle className="h-12 w-12 text-red-500 mx-auto" />
106 <p className="text-lg font-semibold mt-2">Not Found</p>
107 <p className="text-sm text-muted-foreground">No registration with this code</p>
108 </CardContent>
109 </Card>
110 )}
111 </div>
112 );
113}

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

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.

typescript
1// Environment variables to set in V0's Vars tab:
2// STRIPE_SECRET_KEY=sk_live_...
3// STRIPE_WEBHOOK_SECRET=whsec_...
4// NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
5// NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
6// SUPABASE_SERVICE_ROLE_KEY=eyJ...
7// NEXT_PUBLIC_APP_URL=https://your-app.vercel.app
8
9// Helper: increment ticket sold count (Supabase function)
10// Run in Supabase SQL editor:
11//
12// create or replace function increment_ticket_sold(p_ticket_id uuid)
13// returns void as $$
14// begin
15// update tickets set sold_count = sold_count + 1
16// where id = p_ticket_id and sold_count < capacity;
17// end;
18// $$ language plpgsql;
19
20// app/conference/[slug]/register/page.tsx
21import { createClient } from '@/lib/supabase/server';
22import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
23import { Badge } from '@/components/ui/badge';
24import { CheckoutButton } from './checkout-button';
25
26export default async function RegisterPage({
27 params,
28}: {
29 params: Promise<{ slug: string }>;
30}) {
31 const { slug } = await params;
32 const supabase = await createClient();
33
34 const { data: conference } = await supabase
35 .from('conferences')
36 .select('*')
37 .eq('slug', slug)
38 .single();
39
40 if (!conference) return <div>Conference not found</div>;
41
42 const { data: tickets } = await supabase
43 .from('tickets')
44 .select('*')
45 .eq('conference_id', conference.id)
46 .order('price');
47
48 return (
49 <div className="max-w-4xl mx-auto p-6 space-y-6">
50 <h1 className="text-3xl font-bold">Register for {conference.name}</h1>
51 <p className="text-muted-foreground">
52 {new Date(conference.starts_at).toLocaleDateString()} -{' '}
53 {new Date(conference.ends_at).toLocaleDateString()}
54 </p>
55
56 <div className="grid md:grid-cols-3 gap-4">
57 {tickets?.map((ticket) => {
58 const soldOut = ticket.sold_count >= ticket.capacity;
59 return (
60 <Card key={ticket.id} className={soldOut ? 'opacity-60' : ''}>
61 <CardHeader>
62 <CardTitle className="text-lg">{ticket.tier}</CardTitle>
63 <p className="text-3xl font-bold">${ticket.price}</p>
64 </CardHeader>
65 <CardContent>
66 <Badge variant={soldOut ? 'destructive' : 'secondary'}>
67 {soldOut
68 ? 'Sold Out'
69 : `${ticket.capacity - ticket.sold_count} remaining`}
70 </Badge>
71 </CardContent>
72 <CardFooter>
73 <CheckoutButton
74 ticketId={ticket.id}
75 conferenceId={conference.id}
76 disabled={soldOut}
77 />
78 </CardFooter>
79 </Card>
80 );
81 })}
82 </div>
83 </div>
84 );
85}

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

app/conference/[slug]/schedule/page.tsx
1import { createClient } from '@/lib/supabase/server';
2import { Card } from '@/components/ui/card';
3import { Badge } from '@/components/ui/badge';
4import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
5import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
6
7const HOUR_HEIGHT = 80;
8const START_HOUR = 8;
9const END_HOUR = 18;
10
11function getPosition(startTime: string, endTime: string, dayStart: Date) {
12 const start = new Date(startTime);
13 const end = new Date(endTime);
14 const startOffset = (start.getTime() - dayStart.getTime()) / (1000 * 60 * 60);
15 const duration = (end.getTime() - start.getTime()) / (1000 * 60 * 60);
16 return { top: (startOffset - START_HOUR) * HOUR_HEIGHT, height: duration * HOUR_HEIGHT };
17}
18
19export default async function SchedulePage({ params }: { params: Promise<{ slug: string }> }) {
20 const { slug } = await params;
21 const supabase = await createClient();
22
23 const { data: conference } = await supabase.from('conferences').select('*').eq('slug', slug).single();
24 if (!conference) return <div className="p-12 text-center">Conference not found</div>;
25
26 const { data: tracks } = await supabase.from('tracks').select('*').eq('conference_id', conference.id).order('sort_order');
27 const { data: sessions } = await supabase.from('sessions').select('*, session_speakers(speakers(*))').eq('conference_id', conference.id);
28
29 const allTracks = tracks ?? [];
30 const allSessions = sessions ?? [];
31 const dayStart = new Date(conference.starts_at);
32 dayStart.setHours(0, 0, 0, 0);
33 const totalHeight = (END_HOUR - START_HOUR) * HOUR_HEIGHT;
34 const hours = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i);
35
36 return (
37 <div className="max-w-7xl mx-auto p-6">
38 <h1 className="text-3xl font-bold mb-2">{conference.name}</h1>
39 <p className="text-muted-foreground mb-6">{new Date(conference.starts_at).toLocaleDateString()}</p>
40
41 <div className="flex gap-4 overflow-x-auto">
42 <div className="w-16 flex-shrink-0 relative" style={{ height: totalHeight }}>
43 {hours.map(h => (
44 <div key={h} className="absolute text-xs text-muted-foreground" style={{ top: (h - START_HOUR) * HOUR_HEIGHT }}>
45 {h > 12 ? `${h - 12} PM` : h === 12 ? '12 PM' : `${h} AM`}
46 </div>
47 ))}
48 </div>
49
50 {allTracks.map(track => {
51 const trackSessions = allSessions.filter(s => s.track_id === track.id);
52 return (
53 <div key={track.id} className="flex-1 min-w-[200px]">
54 <div className="text-center font-semibold mb-2 pb-2 border-b">
55 <Badge style={{ backgroundColor: track.color, color: '#fff' }}>{track.name}</Badge>
56 </div>
57 <div className="relative" style={{ height: totalHeight }}>
58 {hours.map(h => (
59 <div key={h} className="absolute w-full border-t border-dashed border-muted" style={{ top: (h - START_HOUR) * HOUR_HEIGHT }} />
60 ))}
61 {trackSessions.map(session => {
62 const pos = getPosition(session.starts_at, session.ends_at, dayStart);
63 const speakers = session.session_speakers?.map((ss: any) => ss.speakers) ?? [];
64 return (
65 <Dialog key={session.id}>
66 <DialogTrigger asChild>
67 <button className="absolute left-1 right-1 rounded-md p-2 text-left text-xs text-white overflow-hidden hover:opacity-90 transition-opacity"
68 style={{ top: pos.top, height: pos.height, backgroundColor: track.color }}>
69 <div className="font-semibold truncate">{session.title}</div>
70 <div className="opacity-75">{session.room}</div>
71 </button>
72 </DialogTrigger>
73 <DialogContent>
74 <DialogHeader><DialogTitle>{session.title}</DialogTitle></DialogHeader>
75 <p className="text-sm text-muted-foreground">{session.description}</p>
76 <div className="flex gap-2 mt-2">
77 <Badge variant="outline">{session.room}</Badge>
78 <Badge variant="secondary">{session.registered_count}/{session.capacity}</Badge>
79 </div>
80 {speakers.length > 0 && (
81 <div className="mt-4 space-y-2">
82 <h4 className="font-semibold text-sm">Speakers</h4>
83 {speakers.map((s: any) => (
84 <div key={s.id} className="flex items-center gap-3">
85 <Avatar><AvatarImage src={s.avatar_url} /><AvatarFallback>{s.name.slice(0, 2)}</AvatarFallback></Avatar>
86 <div><p className="font-medium text-sm">{s.name}</p><p className="text-xs text-muted-foreground">{s.company}</p></div>
87 </div>
88 ))}
89 </div>
90 )}
91 </DialogContent>
92 </Dialog>
93 );
94 })}
95 </div>
96 </div>
97 );
98 })}
99 </div>
100 </div>
101 );
102}

Customization ideas

Common pitfalls

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

Pitfall:

How to avoid:

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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I need a multi-track conference schedule component in Next.js App Router. Tracks are columns, time is the Y axis with 80px per hour from 8 AM to 6 PM. Each session is an absolutely positioned colored block based on start/end timestamps. Clicking a session opens a shadcn/ui Dialog with title, description, room, capacity, and speaker avatars. TypeScript with Tailwind CSS.

Build Prompt

Create a Supabase schema for a conference system with sessions that cannot overlap within the same track. Use: CREATE EXTENSION btree_gist; then add an exclusion constraint: EXCLUDE USING gist (track_id WITH =, tstzrange(starts_at, ends_at) WITH &&). Include tables for conferences, tracks, sessions, speakers, tickets, and registrations with QR codes.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.