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.
| Fact | Value |
|---|---|
| Tool | V0 |
| Build time | 2-4 hours |
| Difficulty | Advanced |
| Last updated | April 2026 |
Project overview
Tech stack
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
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.
1-- Enable required extension2create extension if not exists btree_gist;34-- Conferences5create 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);1718-- Tracks19create 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 025);2627-- Sessions with exclusion constraint28create 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 track40 exclude using gist (41 track_id with =,42 tstzrange(starts_at, ends_at) with &&43 )44);4546-- Speakers47create 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);5556-- Session-Speaker junction57create 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);6263-- Tickets64create 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 text72);7374-- Registrations75create 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);8687-- Session RSVPs88create 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);9394-- RLS95alter 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;103104-- Public read for conference content105create 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 select113 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.
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.
1// app/conference/[slug]/schedule/page.tsx2import { 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';89const HOUR_HEIGHT = 80; // pixels per hour10const START_HOUR = 8; // 8 AM11const END_HOUR = 18; // 6 PM1213function 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}2324export default async function SchedulePage({25 params,26}: {27 params: Promise<{ slug: string }>;28}) {29 const { slug } = await params;30 const supabase = await createClient();3132 const { data: conference } = await supabase33 .from('conferences')34 .select('*')35 .eq('slug', slug)36 .single();3738 if (!conference) return <div>Conference not found</div>;3940 const { data: tracks } = await supabase41 .from('tracks')42 .select('*')43 .eq('conference_id', conference.id)44 .order('sort_order');4546 const { data: sessions } = await supabase47 .from('sessions')48 .select('*, session_speakers(speakers(*))')49 .eq('conference_id', conference.id);5051 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;5657 // Generate time labels58 const hours = Array.from({ length: END_HOUR - START_HOUR }, (_, i) => START_HOUR + i);5960 return (61 <div className="max-w-7xl mx-auto p-6">62 <h1 className="text-3xl font-bold mb-6">Schedule</h1>6364 <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 <div69 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>7778 {/* 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 <div92 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 <button105 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} registered125 </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.
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.
1// app/api/checkout/route.ts2import { NextRequest, NextResponse } from 'next/server';3import { createClient } from '@/lib/supabase/server';4import Stripe from 'stripe';56const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);78export async function POST(request: NextRequest) {9 const { ticketId, conferenceId } = await request.json();10 const supabase = await createClient();1112 const { data: { user } } = await supabase.auth.getUser();13 if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });1415 const { data: ticket } = await supabase16 .from('tickets')17 .select('*')18 .eq('id', ticketId)19 .single();2021 if (!ticket || ticket.sold_count >= ticket.capacity) {22 return NextResponse.json({ error: 'Sold out' }, { status: 400 });23 }2425 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 });3940 return NextResponse.json({ url: session.url });41}4243// app/api/webhooks/stripe/route.ts44import { NextRequest, NextResponse } from 'next/server';45import Stripe from 'stripe';46import { createClient } from '@supabase/supabase-js';47import QRCode from 'qrcode';4849const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);5051export async function POST(request: NextRequest) {52 const body = await request.text();53 const sig = request.headers.get('stripe-signature')!;5455 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 }6364 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!;6768 // Use service role for webhook handler69 const supabase = createClient(70 process.env.NEXT_PUBLIC_SUPABASE_URL!,71 process.env.SUPABASE_SERVICE_ROLE_KEY!72 );7374 // Generate unique QR code75 const qrCode = `REG-${crypto.randomUUID().slice(0, 8).toUpperCase()}`;7677 // Create registration78 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 });8586 // Increment sold count87 await supabase.rpc('increment_ticket_sold', { p_ticket_id: ticket_id });88 }8990 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.
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.
1// app/checkin/page.tsx2'use client';34import { 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';1112type CheckinResult = {13 status: 'success' | 'already' | 'not_found';14 registration?: any;15};1617export 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);2324 async function handleCheckin() {25 if (!code.trim()) return;26 setLoading(true);27 setResult(null);2829 const { data: registration } = await supabase30 .from('registrations')31 .select('*, tickets(tier), conferences(name)')32 .eq('qr_code', code.trim().toUpperCase())33 .single();3435 if (!registration) {36 setResult({ status: 'not_found' });37 setLoading(false);38 return;39 }4041 if (registration.checked_in) {42 setResult({ status: 'already', registration });43 setLoading(false);44 return;45 }4647 await supabase48 .from('registrations')49 .update({ checked_in: true, checked_in_at: new Date().toISOString() })50 .eq('id', registration.id);5152 setCheckedCount(prev => prev + 1);53 setResult({ status: 'success', registration });54 setCode('');55 setLoading(false);56 }5758 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>6667 <div className="flex gap-2">68 <Input69 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 autoFocus74 />75 <Button onClick={handleCheckin} disabled={loading}>76 {loading ? 'Checking...' : 'Check In'}77 </Button>78 </div>7980 {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 )}8990 {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 )}101102 {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.
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.
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.co5// NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...6// SUPABASE_SERVICE_ROLE_KEY=eyJ...7// NEXT_PUBLIC_APP_URL=https://your-app.vercel.app89// 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// begin15// update tickets set sold_count = sold_count + 116// where id = p_ticket_id and sold_count < capacity;17// end;18// $$ language plpgsql;1920// app/conference/[slug]/register/page.tsx21import { 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';2526export default async function RegisterPage({27 params,28}: {29 params: Promise<{ slug: string }>;30}) {31 const { slug } = await params;32 const supabase = await createClient();3334 const { data: conference } = await supabase35 .from('conferences')36 .select('*')37 .eq('slug', slug)38 .single();3940 if (!conference) return <div>Conference not found</div>;4142 const { data: tickets } = await supabase43 .from('tickets')44 .select('*')45 .eq('conference_id', conference.id)46 .order('price');4748 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>5556 <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 {soldOut68 ? 'Sold Out'69 : `${ticket.capacity - ticket.sold_count} remaining`}70 </Badge>71 </CardContent>72 <CardFooter>73 <CheckoutButton74 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
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';67const HOUR_HEIGHT = 80;8const START_HOUR = 8;9const END_HOUR = 18;1011function 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}1819export default async function SchedulePage({ params }: { params: Promise<{ slug: string }> }) {20 const { slug } = await params;21 const supabase = await createClient();2223 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>;2526 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);2829 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);3536 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>4041 <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>4950 {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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation