Skip to main content
RapidDev - Software Development Agency

How to Build a Event Registration System with Lovable

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'll build

  • Event pages with cover images, descriptions, date/location, and multiple ticket types
  • Ticket types with individual capacities, prices, and sale windows per ticket
  • Custom registration form fields (text, select, checkbox) defined per event by organizers
  • Atomic capacity management via a Postgres function to prevent overselling under concurrent load
  • Automatic waitlist fallback when a ticket type sells out, with position tracking
  • QR code generation per registration and a mobile-friendly check-in scanner page
  • Organizer dashboard showing attendee list, check-in stats, and CSV export
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate16 min read2–2.5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend app builder
SupabaseDatabase, Auth, Storage, Edge Functions
shadcn/uiCard, Dialog, Badge, Table, Progress, Tabs
qrcode.reactQR code rendering in confirmation and check-in
react-hook-form + zodDynamic registration form validation
Supabase Edge FunctionsConfirmation email via Resend (Deno)

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

1

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.

prompt.txt
1Create a Supabase schema for an event registration system.
2
3Tables:
4- organizers: id (uuid pk references auth.users), name (text), email (text), created_at
5- 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_at
6- 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_at
9- 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_at
10
11Constraints:
12- CHECK (ticket_types.remaining_capacity >= 0)
13- UNIQUE INDEX on registrations(event_id, registrant_email, ticket_type_id) WHERE status = 'confirmed'
14
15RLS:
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_event
19- event_form_fields: SELECT public; INSERT/UPDATE/DELETE organizer only
20
21Create 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 $$
26DECLARE
27 v_ticket ticket_types;
28 v_code text;
29 v_waitlist_pos int;
30BEGIN
31 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 THEN
34 RETURN jsonb_build_object('success', false, 'error', 'Ticket sales are not open');
35 END IF;
36 IF v_ticket.remaining_capacity > 0 THEN
37 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 ELSE
43 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.

2

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.

prompt.txt
1Build a public event page at src/pages/EventPage.tsx with route /events/:slug.
2
3On 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)
7
8Layout:
9- Hero section: cover image (aspect-video, object-cover), event title overlay
10- 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 icon
12- Ticket panel as a Card:
13 - List of ticket types, each showing: name, description, price (Free or $X.XX), remaining capacity, sale end date
14 - For each ticket type: 'Register' Button if remaining_capacity > 0 and within sale window
15 - If remaining_capacity = 0: 'Join Waitlist' Button (secondary variant)
16 - If outside sale window: 'Sales Closed' Badge
17 - 'Register' or 'Join Waitlist' click sets selectedTicketType and opens RegistrationDialog
18
19RegistrationDialog:
20- Show selected ticket type name and price
21- Form fields: registrant_name, registrant_email
22- Dynamically render event_form_fields: each field_type maps to the correct input component
23 - text Input
24 - email Input (type='email')
25 - select Select with options from options_json
26 - checkbox Checkbox
27 - number Input (type='number')
28- Submit calls supabase.rpc('register_for_event', { ... })
29- On success with type='registered': show confirmation with code
30- 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.

3

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.

prompt.txt
1Two components to build:
2
31. Update RegistrationConfirmation component:
4- After successful registration, render a qrcode.react QRCodeSVG component with value = registration_code
5- Size: 200x200 pixels, with a quiet zone
6- 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 download
9- Add 'Add to Apple Wallet' placeholder Button (show as grayed out with 'Coming soon')
10
112. 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' Button
17- On code submit:
18 1. Fetch registration WHERE registration_code = ? AND event_id = selectedEvent
19 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.

4

Build the organizer dashboard

Ask Lovable to build the organizer's dashboard for managing events, viewing attendees, and exporting data.

prompt.txt
1Build an organizer dashboard at src/pages/OrganizerDashboard.tsx (requires auth).
2
3Event 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 fields
6- Clicking an event opens the event management page
7
8Event management page (route /manage/:eventId):
9- Tabs: 'Overview', 'Attendees', 'Tickets', 'Form Fields', 'Waitlist'
10
11Overview tab:
12- Stats Cards: total capacity, registrations, checked in, waitlisted
13- Donut chart (Recharts) showing registrations by ticket type
14
15Attendees tab:
16- DataTable with columns: name, email, ticket type Badge, registration code (monospace), checked_in Badge (Yes/No), registration date
17- Search by name or email
18- Bulk actions: 'Export CSV' downloads all attendee data as a CSV file
19- Individual row: 'Cancel Registration' with AlertDialog confirmation
20
21Tickets tab:
22- Table showing each ticket type: name, total_capacity, remaining_capacity, registrations count
23- Edit Button per ticket type for updating sale window and capacity
24
25Form Fields tab:
26- Drag-to-reorder list of form fields
27- Add/edit/delete fields
28
29Waitlist tab:
30- List of waitlisted people by position
31- 'Notify Next' Button that sends an email to the next person on the waitlist (calls Edge Function) and marks them as notified

Expected result: Organizers can view all their events, see attendees in a searchable DataTable, export CSV, cancel individual registrations, and manage the waitlist.

Complete code

src/components/DynamicRegistrationForm.tsx
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'
8
9type FormField = {
10 field_key: string
11 field_label: string
12 field_type: 'text' | 'email' | 'select' | 'checkbox' | 'number'
13 options_json: string[] | null
14 is_required: boolean
15}
16
17type Props = {
18 fields: FormField[]
19 onSubmit: (answers: Record<string, unknown>) => void
20 isLoading: boolean
21}
22
23function buildSchema(fields: FormField[]) {
24 const shape: Record<string, z.ZodTypeAny> = {}
25 for (const f of fields) {
26 let fieldSchema: z.ZodTypeAny
27 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_required
33 ? z.string().min(1, `${f.field_label} is required`)
34 : z.string().optional()
35 }
36 shape[f.field_key] = fieldSchema
37 }
38 return z.object(shape)
39}
40
41export function DynamicRegistrationForm({ fields, onSubmit, isLoading }: Props) {
42 const schema = buildSchema(fields)
43 const form = useForm({ resolver: zodResolver(schema) })
44
45 return (
46 <Form {...form}>
47 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
48 {fields.map((field) => (
49 <FormField
50 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

RapidDev

Talk to an Expert

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

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

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.