Build a ticketed event registration system in Lovable with capacity management, multiple ticket types, custom registration fields, an automatic waitlist when events sell out, and QR code check-in for attendees. A Postgres function handles atomic capacity decrement to prevent overselling. Build time is roughly 2.5 hours.
What you're building
Event registration has two tricky parts: preventing overselling and supporting custom data per registration. Both are handled at the architecture level before any UI is built.
Overselling prevention uses the same pattern as the test booking system: a Postgres function register_for_event(p_ticket_type_id, p_registrant_email, ...) that SELECT ... FOR UPDATE locks the ticket_types row, checks remaining_capacity > 0, decrements it, and inserts the registration — all atomically. If capacity is 0, the function instead inserts a row into event_waitlist with a position number computed by MAX(position) + 1 for that ticket type.
Custom registration fields are defined by organizers in an event_form_fields table (event_id, field_key, field_label, field_type, is_required, options_json). The registration page fetches these fields and renders a dynamic form using react-hook-form with zod validation built from the field definitions. Answers are stored as a JSONB column in registrations.
QR codes are generated client-side using the qrcode.react library from the registration's unique code. The check-in page is a mobile-friendly scanner that lets organizers either scan a QR code via the device camera (using the html5-qrcode library) or manually type a code to mark attendees as checked in.
Final result
A complete event registration system with multi-tier ticketing, dynamic forms, waitlist management, and QR check-in — backed by Supabase with atomic capacity protection.
Tech stack
Prerequisites
- Lovable Pro account for multi-component and Edge Function generation
- Supabase project with Auth enabled for organizer accounts
- Supabase credentials saved to Cloud tab → Secrets
- Optional: Resend account for registration confirmation emails
- Optional: a smartphone to test the QR code check-in scanner
Build steps
Create the event registration schema with the atomic register function
Prompt Lovable to create the full schema including ticket types, custom form fields, waitlist, and the register_for_event Postgres function that handles both registration and waitlist insertion atomically.
1Create a Supabase schema for an event registration system.23Tables:4- organizers: id (uuid pk references auth.users), name (text), email (text), created_at5- events: id (uuid pk), organizer_id (references organizers), title (text), description (text), cover_image_url (text), event_date (timestamptz), end_date (timestamptz), location (text), is_online (bool default false), online_link (text), status (text default 'published' check: draft|published|cancelled|completed), slug (text unique), created_at6- ticket_types: id (uuid pk), event_id (references events), name (text), description (text), price_cents (int default 0), total_capacity (int), remaining_capacity (int), sale_start (timestamptz), sale_end (timestamptz), sort_order (int default 0)7- event_form_fields: id (uuid pk), event_id (references events), field_key (text), field_label (text), field_type (text check: text|email|select|checkbox|number), options_json (jsonb, for select type: ['Option A', 'Option B']), is_required (bool default false), sort_order (int)8- registrations: id (uuid pk), event_id (references events), ticket_type_id (references ticket_types), registrant_name (text), registrant_email (text), registration_code (text unique, 10 chars uppercase), form_answers (jsonb), checked_in (bool default false), checked_in_at (timestamptz), status (text default 'confirmed' check: confirmed|cancelled), created_at9- event_waitlist: id (uuid pk), event_id (references events), ticket_type_id (references ticket_types), email (text), name (text), position (int), notified (bool default false), created_at1011Constraints:12- CHECK (ticket_types.remaining_capacity >= 0)13- UNIQUE INDEX on registrations(event_id, registrant_email, ticket_type_id) WHERE status = 'confirmed'1415RLS:16- events: SELECT public; INSERT/UPDATE/DELETE where organizer_id = auth.uid()17- ticket_types: SELECT public; INSERT/UPDATE/DELETE where event_id IN (SELECT id FROM events WHERE organizer_id = auth.uid())18- registrations: SELECT where event_id IN (SELECT id FROM events WHERE organizer_id = auth.uid()); INSERT handled by register_for_event19- event_form_fields: SELECT public; INSERT/UPDATE/DELETE organizer only2021Create atomic function:22CREATE OR REPLACE FUNCTION register_for_event(23 p_event_id uuid, p_ticket_type_id uuid,24 p_name text, p_email text, p_form_answers jsonb DEFAULT '{}'25) RETURNS jsonb LANGUAGE plpgsql SECURITY DEFINER AS $$26DECLARE27 v_ticket ticket_types;28 v_code text;29 v_waitlist_pos int;30BEGIN31 SELECT * INTO v_ticket FROM ticket_types WHERE id = p_ticket_type_id AND event_id = p_event_id FOR UPDATE;32 IF NOT FOUND THEN RETURN jsonb_build_object('success', false, 'error', 'Ticket type not found'); END IF;33 IF now() < v_ticket.sale_start OR now() > v_ticket.sale_end THEN34 RETURN jsonb_build_object('success', false, 'error', 'Ticket sales are not open');35 END IF;36 IF v_ticket.remaining_capacity > 0 THEN37 v_code := upper(substring(md5(random()::text || clock_timestamp()::text) from 1 for 10));38 UPDATE ticket_types SET remaining_capacity = remaining_capacity - 1 WHERE id = p_ticket_type_id;39 INSERT INTO registrations (event_id, ticket_type_id, registrant_name, registrant_email, registration_code, form_answers)40 VALUES (p_event_id, p_ticket_type_id, p_name, p_email, v_code, p_form_answers);41 RETURN jsonb_build_object('success', true, 'type', 'registered', 'code', v_code);42 ELSE43 SELECT COALESCE(MAX(position), 0) + 1 INTO v_waitlist_pos FROM event_waitlist WHERE ticket_type_id = p_ticket_type_id;44 INSERT INTO event_waitlist (event_id, ticket_type_id, email, name, position) VALUES (p_event_id, p_ticket_type_id, p_email, p_name, v_waitlist_pos);45 RETURN jsonb_build_object('success', true, 'type', 'waitlisted', 'position', v_waitlist_pos);46 END IF;47END;48$$;Pro tip: Add a unique constraint on event_waitlist(ticket_type_id, email) to prevent the same email from joining the waitlist twice for the same ticket type. The register_for_event function should check for an existing waitlist entry before inserting.
Expected result: All tables are created with CHECK constraints and the partial unique index. The register_for_event function exists and handles both registration and waitlist insertion. RLS is properly configured.
Build the public event page
Ask Lovable to build the public event detail page where visitors can view event info and buy tickets. The page shows ticket availability and opens the registration Dialog.
1Build a public event page at src/pages/EventPage.tsx with route /events/:slug.23On load:4- Fetch event by slug (title, description, cover_image_url, event_date, end_date, location, is_online, online_link)5- Fetch ticket_types for this event (ordered by sort_order)6- Fetch event_form_fields for this event (ordered by sort_order)78Layout:9- Hero section: cover image (aspect-video, object-cover), event title overlay10- Two-column layout on desktop: event details (left, 2/3) and ticket panel (right, 1/3)11- Event details: description (rendered as plain text with line breaks), date/time, location with map pin icon12- Ticket panel as a Card:13 - List of ticket types, each showing: name, description, price (Free or $X.XX), remaining capacity, sale end date14 - For each ticket type: 'Register' Button if remaining_capacity > 0 and within sale window15 - If remaining_capacity = 0: 'Join Waitlist' Button (secondary variant)16 - If outside sale window: 'Sales Closed' Badge17 - 'Register' or 'Join Waitlist' click sets selectedTicketType and opens RegistrationDialog1819RegistrationDialog:20- Show selected ticket type name and price21- Form fields: registrant_name, registrant_email22- Dynamically render event_form_fields: each field_type maps to the correct input component23 - text → Input24 - email → Input (type='email')25 - select → Select with options from options_json26 - checkbox → Checkbox27 - number → Input (type='number')28- Submit calls supabase.rpc('register_for_event', { ... })29- On success with type='registered': show confirmation with code30- On success with type='waitlisted': show 'You are #[position] on the waitlist'Pro tip: Subscribe to Supabase Realtime on ticket_types for this event. When remaining_capacity changes, update the displayed counts and button states live. This prevents a visitor from clicking 'Register' on a ticket that just sold out.
Expected result: The event page shows all details and ticket types. Registration opens the dynamic form Dialog. Successful registration shows the code. Sold-out tickets show the waitlist option.
Build the QR code confirmation and check-in system
Ask Lovable to add QR code generation to the confirmation screen and build a separate mobile-friendly check-in page for event organizers to scan attendees.
1Two components to build:231. Update RegistrationConfirmation component:4- After successful registration, render a qrcode.react QRCodeSVG component with value = registration_code5- Size: 200x200 pixels, with a quiet zone6- Show registration_code as text below the QR code (large, monospace)7- Instructions: 'Show this QR code or your code at event check-in'8- Add a 'Download QR Code' Button that converts the SVG to a PNG using canvas and triggers a download9- Add 'Add to Apple Wallet' placeholder Button (show as grayed out with 'Coming soon')10112. Build a check-in page at src/pages/CheckIn.tsx (requires organizer auth):12- Show event selector (Select of organizer's upcoming events)13- Two tabs: 'Scan QR Code' and 'Manual Entry'14- Scan tab: integrate html5-qrcode library to access device camera. Show a viewfinder box.15 On successful QR scan, auto-submit the code.16- Manual Entry tab: an Input for the registration_code with a 'Check In' Button17- On code submit:18 1. Fetch registration WHERE registration_code = ? AND event_id = selectedEvent19 2. If not found: show red Alert 'Registration code not found'20 3. If already checked in: show yellow Alert 'Already checked in at [time]'21 4. If found and not checked in: UPDATE registrations SET checked_in = true, checked_in_at = now()22 Show green Alert 'Welcome, [name]! [ticket_type_name]'23- Show a stats bar below: X of Y attendees checked in (live count)Pro tip: For the camera check-in to work on iOS Safari, the check-in page must be served over HTTPS and the user must allow camera permissions. Lovable's published URL is always HTTPS, so this works after publishing. The Preview inside Lovable uses localhost, which also allows camera access.
Expected result: The confirmation screen shows a scannable QR code with the registration code. The check-in page lets organizers scan or manually enter codes. Successful check-in shows the attendee's name and ticket type.
Build the organizer dashboard
Ask Lovable to build the organizer's dashboard for managing events, viewing attendees, and exporting data.
1Build an organizer dashboard at src/pages/OrganizerDashboard.tsx (requires auth).23Event list page:4- Show all events for the authenticated organizer as Cards (title, date, status Badge, attendee count)5- 'Create Event' Button that opens an EventCreateDialog with all event fields6- Clicking an event opens the event management page78Event management page (route /manage/:eventId):9- Tabs: 'Overview', 'Attendees', 'Tickets', 'Form Fields', 'Waitlist'1011Overview tab:12- Stats Cards: total capacity, registrations, checked in, waitlisted13- Donut chart (Recharts) showing registrations by ticket type1415Attendees tab:16- DataTable with columns: name, email, ticket type Badge, registration code (monospace), checked_in Badge (Yes/No), registration date17- Search by name or email18- Bulk actions: 'Export CSV' downloads all attendee data as a CSV file19- Individual row: 'Cancel Registration' with AlertDialog confirmation2021Tickets tab:22- Table showing each ticket type: name, total_capacity, remaining_capacity, registrations count23- Edit Button per ticket type for updating sale window and capacity2425Form Fields tab:26- Drag-to-reorder list of form fields27- Add/edit/delete fields2829Waitlist tab:30- List of waitlisted people by position31- 'Notify Next' Button that sends an email to the next person on the waitlist (calls Edge Function) and marks them as notifiedExpected result: Organizers can view all their events, see attendees in a searchable DataTable, export CSV, cancel individual registrations, and manage the waitlist.
Complete code
1import { useForm } from 'react-hook-form'2import { zodResolver } from '@hookform/resolvers/zod'3import { z } from 'zod'4import { Input } from '@/components/ui/input'5import { Checkbox } from '@/components/ui/checkbox'6import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'7import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'89type FormField = {10 field_key: string11 field_label: string12 field_type: 'text' | 'email' | 'select' | 'checkbox' | 'number'13 options_json: string[] | null14 is_required: boolean15}1617type Props = {18 fields: FormField[]19 onSubmit: (answers: Record<string, unknown>) => void20 isLoading: boolean21}2223function buildSchema(fields: FormField[]) {24 const shape: Record<string, z.ZodTypeAny> = {}25 for (const f of fields) {26 let fieldSchema: z.ZodTypeAny27 if (f.field_type === 'checkbox') {28 fieldSchema = z.boolean()29 } else if (f.field_type === 'number') {30 fieldSchema = f.is_required ? z.coerce.number() : z.coerce.number().optional()31 } else {32 fieldSchema = f.is_required33 ? z.string().min(1, `${f.field_label} is required`)34 : z.string().optional()35 }36 shape[f.field_key] = fieldSchema37 }38 return z.object(shape)39}4041export function DynamicRegistrationForm({ fields, onSubmit, isLoading }: Props) {42 const schema = buildSchema(fields)43 const form = useForm({ resolver: zodResolver(schema) })4445 return (46 <Form {...form}>47 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">48 {fields.map((field) => (49 <FormField50 key={field.field_key}51 control={form.control}52 name={field.field_key}53 render={({ field: f }) => (54 <FormItem>55 <FormLabel>{field.field_label}{field.is_required && <span className="text-destructive ml-1">*</span>}</FormLabel>56 <FormControl>57 {field.field_type === 'select' && field.options_json ? (58 <Select onValueChange={f.onChange} defaultValue={f.value}>59 <SelectTrigger><SelectValue placeholder="Select an option" /></SelectTrigger>60 <SelectContent>61 {field.options_json.map((opt) => (62 <SelectItem key={opt} value={opt}>{opt}</SelectItem>63 ))}64 </SelectContent>65 </Select>66 ) : field.field_type === 'checkbox' ? (67 <Checkbox checked={f.value} onCheckedChange={f.onChange} />68 ) : (69 <Input type={field.field_type} {...f} />70 )}71 </FormControl>72 <FormMessage />73 </FormItem>74 )}75 />76 ))}77 </form>78 </Form>79 )80}Customization ideas
Stripe payment for paid tickets
When a ticket type has price_cents > 0, call an Edge Function that creates a Stripe Checkout session instead of calling register_for_event directly. On Stripe webhook checkout.session.completed, call register_for_event with the payment metadata. The booking confirmation page is the Stripe success URL. Free tickets call register_for_event directly.
Event series and recurring events
Add a parent_event_id column to events (self-referencing). When creating a recurring event, generate child event rows for each occurrence. The public event listing page groups series events together with a 'View all dates' Accordion. Registrations are always for a specific child event, not the parent.
Group registration for teams
Add a group_registrations table with a group_code. When an organizer creates an event, they can enable group registration with a max team size. The first registrant creates a group and gets a group_code. Subsequent registrants enter the group_code to join. All group members share check-in as a unit.
Attendee networking — shared profile discovery
Add an opt_in_networking boolean to the registration form. For opted-in attendees, show a public attendee directory on the event page (after registration) listing names, companies, and LinkedIn handles. Each attendee card has a 'Connect' button that sends a Resend email introduction to both parties.
Post-event survey and feedback
After the event_date passes, send a feedback email to all checked-in attendees via a cron Edge Function. The email links to a post-event survey page using the same dynamic form field system with rating (1–5) and open-text fields. Survey results display in the organizer dashboard.
Common pitfalls
Pitfall: Checking capacity and inserting in two separate database operations
How to avoid: Use the register_for_event Postgres function with SELECT ... FOR UPDATE. The lock ensures atomicity. The CHECK constraint (remaining_capacity >= 0) is the additional safeguard.
Pitfall: Storing dynamic form answers as separate rows instead of JSONB
How to avoid: Store all form answers as a single JSONB column in the registrations row: { 'dietary_requirements': 'Vegan', 'company': 'Acme Corp' }. The organizer CSV export reads the JSONB keys and flattens them to columns.
Pitfall: Not validating that ticket sales are open before allowing registration
How to avoid: The register_for_event function checks sale_start and sale_end server-side and returns an error if outside the window. Also disable the Register button on the frontend using now() < sale_start || now() > sale_end on the fetched ticket data.
Pitfall: Generating QR codes that only contain the registration code without an event identifier
How to avoid: Encode both the event ID and registration code in the QR value: eventId + ':' + registrationCode. The check-in page parses both values and validates that the event matches the selected event before accepting the check-in.
Best practices
- Use a Postgres function with SELECT ... FOR UPDATE for registration to make capacity decrement atomic. This is non-negotiable for any event with more than a handful of registrants.
- Store custom registration form answers as JSONB on the registrations row. This keeps the schema clean and makes bulk export simple — flatten the JSONB keys to CSV columns in the export function.
- Subscribe to Supabase Realtime on ticket_types for the event being viewed. Update remaining capacity counts live so visitors see accurate availability without refreshing.
- Generate registration codes server-side in the Postgres function, not in the frontend. Server-generated codes based on random bytes and clock_timestamp() are unpredictable and collision-resistant.
- Add a unique partial index on registrations(event_id, registrant_email, ticket_type_id) WHERE status = 'confirmed' to prevent duplicate registrations for the same ticket type per event.
- Validate ticket sale windows (sale_start and sale_end) both in the Postgres function and in the frontend UI. Server-side validation is authoritative; frontend validation is UX.
- Make the check-in page work offline or with a slow connection by caching the full attendee list locally on the device when the organizer loads it. Check-ins update Supabase when connectivity is restored.
- Include the event_id in the QR code value alongside the registration code to prevent cross-event code conflicts when an organizer manages multiple events.
AI prompts to try
Copy these prompts to build this project faster.
I'm building an event registration system with dynamic custom form fields. Each event has form fields defined in an event_form_fields table with field_key, field_label, field_type (text/email/select/checkbox/number), options_json, and is_required. Help me write a React hook useDynamicFormSchema(fields) that takes this array and returns a Zod schema and default values object. The schema should make required fields produce meaningful validation messages using the field_label, and handle select fields by validating against the allowed options_json array. Show me how to use this schema with react-hook-form's zodResolver.
Add a 'Share event' section to the event management page. Generate a shareable link for the event at /events/[slug]. Add an 'Embed registration widget' option that generates an iframe snippet: <iframe src='/embed/events/[slug]' width='400' height='600'></iframe>. Build the embedded version at src/pages/EventEmbed.tsx — same registration form but without the event header/hero image, designed to fit in a narrow iframe. The embed page should have no navigation and a minimal layout.
In Supabase, create a database function export_event_registrations(p_event_id uuid) that returns all confirmed registrations for an event as a formatted result set. The function should flatten the form_answers JSONB column — for each unique key across all registrations, create a column. Return standard columns (registration_code, registrant_name, registrant_email, ticket_type_name, checked_in, created_at) plus one column per form field key. The function should be callable by authenticated organizers who own the event. Make it SECURITY INVOKER so RLS applies.
Frequently asked questions
What happens when the last seat is taken between the time a user opens the form and submits it?
The register_for_event Postgres function handles this case. When the function runs, it checks remaining_capacity with a row lock at that exact moment. If capacity is 0, it inserts a waitlist entry and returns type: 'waitlisted' instead of type: 'registered'. The UI shows 'You are #X on the waitlist' as the confirmation rather than a registration code.
How do I move someone from the waitlist to a confirmed registration?
When a registration is cancelled, the remaining_capacity is incremented. A Supabase trigger or cron Edge Function then checks the event_waitlist for the next person (position = MIN), marks them as notified, and calls an Edge Function that emails them a time-limited registration link. When they click it, the link calls register_for_event with their details. If they do not register within the deadline, the next person on the waitlist is notified.
Can the QR code check-in page work without internet on the day of the event?
For offline support, load and cache the full registration list for the event when the organizer opens the check-in page. Store it in IndexedDB or localStorage. On check-in, update the local cache immediately and sync changes to Supabase when connectivity returns. This requires additional engineering beyond this guide's scope.
How do I prevent the same email from registering multiple times?
The partial unique index on registrations(event_id, registrant_email, ticket_type_id) WHERE status = 'confirmed' prevents duplicate confirmed registrations at the database level. The register_for_event function should also check for an existing confirmed registration and return an error: 'You are already registered for this ticket type.' Handle that error in the UI.
Can I support both free and paid ticket types on the same event?
Yes. In the ticket panel, check price_cents for each ticket type. Free tickets (price_cents = 0) call register_for_event directly. Paid tickets call an Edge Function that creates a Stripe Checkout session. After payment, the Stripe webhook calls register_for_event. Both paths lead to the same confirmation screen.
How do I let organizers limit one ticket type per registrant but allow multiple ticket types per event?
The current partial unique index on (event_id, registrant_email, ticket_type_id) already prevents duplicate registrations per ticket type per email. This allows one Standard ticket and one VIP ticket per email on the same event. If you want to limit to one total registration per email regardless of ticket type, change the unique index to just (event_id, registrant_email).
Where can I get help if I need to add Stripe payments or a waitlist notification workflow?
RapidDev builds complete Lovable apps. If you need Stripe integration, automated waitlist management, post-event surveys, or certificate generation connected to this registration system, reach out to discuss a scoped build.
How do I handle the case where an organizer needs to add capacity to a sold-out ticket type?
Add a simple form in the organizer dashboard to increase total_capacity and set remaining_capacity = remaining_capacity + (new_total - old_total). This does not automatically notify the waitlist — that is a separate 'Notify next waitlisted person' action the organizer triggers manually, or you can automate it with a trigger.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation