# How to Build a Conference Management System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Advanced
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

## TL;DR

Build a full conference management system in Lovable covering the complete event lifecycle: a call-for-proposals submission portal, speaker profile management, a multi-track schedule builder with drag-and-drop session ordering, Stripe-powered attendee registration with ticket tiers, and conflict validation to prevent double-booking the same room or speaker. Build time is approximately 4 hours.

## Before you start

- Lovable Pro account for multi-file Edge Function generation
- Supabase project with Auth enabled for organizer accounts
- Stripe account with test mode API keys (publishable and secret)
- STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and SUPABASE_SERVICE_ROLE_KEY in Cloud tab → Secrets
- Optional: Resend account for speaker acceptance and registration confirmation emails
- Basic understanding of Stripe Checkout and webhook event handling

## Step-by-step guide

### 1. Create the conference schema

Prompt Lovable to generate the full database schema. This is a large schema — take it in one prompt so Lovable generates consistent foreign key references and RLS across all tables.

```
Create a Supabase schema for a conference management system.

Tables:
- conferences: id (uuid pk), organizer_id (references auth.users), name (text), tagline (text), start_date (date), end_date (date), venue (text), city (text), website_url (text), logo_url (text), cfp_open (bool default false), cfp_deadline (timestamptz), is_published (bool default false), created_at

- ticket_tiers: id (uuid pk), conference_id (references conferences), name (text), description (text), price_cents (int), total_quantity (int), remaining_quantity (int), sale_start (timestamptz), sale_end (timestamptz), sort_order (int)

- cfp_proposals: id (uuid pk), conference_id (references conferences), speaker_name (text), speaker_email (text), speaker_bio (text), speaker_company (text), speaker_photo_url (text), talk_title (text), talk_abstract (text), talk_format (text check: talk|workshop|panel|lightning), preferred_duration_minutes (int), technical_level (text check: beginner|intermediate|advanced), tags (text array), status (text default 'pending' check: pending|accepted|rejected|waitlisted), organizer_notes (text), submitted_at (timestamptz default now())

- speakers: id (uuid pk), conference_id (references conferences), proposal_id (references cfp_proposals), name (text), email (text), bio (text), company (text), photo_url (text), website_url (text), twitter_handle (text), linkedin_url (text)

- tracks: id (uuid pk), conference_id (references conferences), name (text), color (text), room (text), sort_order (int)

- sessions: id (uuid pk), conference_id (references conferences), track_id (references tracks), speaker_id (references speakers nullable), title (text), description (text), format (text), duration_minutes (int), scheduled_start (timestamptz nullable), scheduled_end (timestamptz nullable), room (text), status (text default 'draft' check: draft|confirmed|cancelled), sort_order (int)

- attendees: id (uuid pk), conference_id (references conferences), ticket_tier_id (references ticket_tiers), name (text), email (text), company (text), registration_code (text unique), stripe_payment_intent_id (text), status (text default 'confirmed' check: confirmed|cancelled), checked_in (bool default false), checked_in_at (timestamptz), created_at

RLS:
- conferences: SELECT public; INSERT/UPDATE where organizer_id = auth.uid()
- ticket_tiers/tracks/sessions: SELECT public; INSERT/UPDATE/DELETE where conference_id IN (SELECT id FROM conferences WHERE organizer_id = auth.uid())
- cfp_proposals: SELECT where conference_id IN organizer's conferences OR speaker_email = auth.jwt()->>'email'; INSERT public
- speakers: SELECT public; INSERT/UPDATE/DELETE organizer only
- attendees: SELECT where conference_id in organizer's conferences; INSERT by SECURITY DEFINER function only

Create a function register_attendee(p_conference_id uuid, p_tier_id uuid, p_name text, p_email text, p_company text, p_payment_intent_id text) RETURNS jsonb SECURITY DEFINER that atomically decrements remaining_quantity and inserts the attendee row with a generated registration_code.
```

> Pro tip: Ask Lovable to generate the TypeScript types file immediately after the schema creation: 'Run supabase gen types and write the result to src/types/database.types.ts'. Use these types throughout the build to catch mismatches early.

**Expected result:** All eight tables are created with appropriate constraints and RLS. The register_attendee SECURITY DEFINER function exists. Ticket tier quantity fields have CHECK constraints preventing negative values.

### 2. Build the CFP submission portal and organizer review dashboard

Ask Lovable to build both the public CFP submission form and the organizer's review interface. These are two separate pages but tightly coupled through the cfp_proposals table.

```
Build two pages:

1. Public CFP submission page at src/pages/CFPSubmit.tsx (route /cfp/:conferenceSlug):
- Fetch conference to confirm cfp_open = true and cfp_deadline is in the future
- If CFP closed, show 'Submissions are now closed' message
- Form (react-hook-form + zod):
  - speaker_name, speaker_email, speaker_bio (Textarea 50–500 words), speaker_company
  - speaker_photo upload (Supabase Storage, returns URL)
  - talk_title, talk_abstract (Textarea 300–1000 words with live word count)
  - talk_format: Select (Talk, Workshop, Panel, Lightning Talk)
  - preferred_duration_minutes: Select (15/30/45/60/90)
  - technical_level: RadioGroup (Beginner, Intermediate, Advanced)
  - tags: multi-value Input (e.g. React, TypeScript, Architecture)
- On submit: INSERT into cfp_proposals
- Confirmation: 'Your proposal was submitted. Reference: [proposal id first 8 chars]'

2. Organizer CFP review page at src/pages/CFPReview.tsx (requires auth):
- Show proposal count by status in Badge pills: Pending (N), Accepted (N), Rejected (N), Waitlisted (N)
- Tabs for each status
- Each proposal as a Card:
  - Speaker photo avatar, name, company
  - Talk title (large), format Badge, duration Badge, level Badge
  - Abstract truncated to 3 lines with 'Read more' expand
  - Action Buttons: 'Accept', 'Reject', 'Waitlist', 'View Full'
  - 'View Full' opens a Sheet with complete proposal details and organizer_notes Textarea
- Accept action: UPDATE status='accepted', then INSERT into speakers using proposal data
- Show a progress bar: X of Y proposals reviewed
- Filter by format, level, and tags
```

> Pro tip: When a proposal is accepted, automatically create the speaker record AND a draft session in a transaction. Ask Lovable: 'When accepting a proposal, also INSERT a session row with status=draft, title=proposal.talk_title, speaker_id=new speaker id, duration_minutes=proposal.preferred_duration_minutes. Leave scheduled_start as null until the organizer places it in the schedule builder.'

**Expected result:** Speakers can submit proposals via the public form. Organizers can review proposals by status, read full abstracts, and accept/reject. Accepting a proposal creates a speaker and draft session.

### 3. Build the multi-track schedule builder with conflict validation

Ask Lovable to build the schedule builder. Tracks are columns, sessions are cards within each column, and dnd-kit handles drag-and-drop. A constraint validator runs before each drop to check for speaker and room conflicts.

```
Build a schedule builder at src/pages/ScheduleBuilder.tsx (requires auth).

Layout:
- Horizontal scroll container with one column per track
- Each column header: track name (colored with track.color), room name, add session Button
- Each column body: list of session Cards sorted by sort_order
- Unscheduled sessions panel on the left: sessions with scheduled_start = null
- Day selector at the top if conference spans multiple days

Drag-and-drop (dnd-kit):
- Use DndContext, SortableContext, useSortable hooks
- Sessions can be dragged from the unscheduled panel into any track column
- Sessions can be reordered within a track column
- Sessions can be moved between track columns

Time assignment:
- When a session is dropped into a track column at a position, show a time picker popover to assign scheduled_start
- scheduled_end = scheduled_start + session.duration_minutes

Conflict validation (run before confirming any drop):
function validateDrop(session, targetTrack, proposedStart, allSessions): ValidationResult
1. If session has a speaker_id, check if that speaker has another session in allSessions where the time ranges overlap
2. If targetTrack has a room, check if another session in the same track/room has overlapping times
3. Return { valid: true } or { valid: false, conflict: 'Speaker [name] is already scheduled at this time in Track B' }

If validation fails:
- Cancel the drop (do not update the database)
- Show a Toast with the conflict message
- Highlight the conflicting session with a red border for 3 seconds

Session Card component:
- Title, speaker name (if assigned), duration Badge, format Badge
- Status indicator: draft=gray dot, confirmed=green dot
- 'Edit' icon opens session edit Dialog
- Grip handle for drag initiation
```

> Pro tip: Implement optimistic updates for drag-and-drop. Update the local session state immediately on drop, then persist to Supabase. If the Supabase update fails, revert to the previous state and show an error toast. This makes drag-and-drop feel instant even on slow connections.

**Expected result:** The schedule builder shows tracks as columns. Sessions drag between tracks and the unscheduled panel. Dropping a session with a conflicting speaker or room shows an error toast and cancels the drop.

### 4. Build the Stripe-powered attendee registration

Ask Lovable to build the attendee registration flow with Stripe Checkout for paid tickets and the webhook Edge Function that confirms registrations after payment.

```
Build two components:

1. Public registration page at src/pages/AttendeeRegistration.tsx:
- Show conference details and ticket tiers as Cards
- Each tier: name, description, price, remaining quantity, sale window
- 'Register' Button disabled if sold out or outside sale window
- Registration form (free tier, no Stripe):
  - name, email, company (optional)
  - Submit calls register_attendee RPC function directly
- Registration flow (paid tier, with Stripe):
  - Collect name, email, company in a form first
  - Submit calls create-checkout Edge Function
  - Redirect to Stripe Checkout
  - Success URL: /conference/[slug]/registration-success?session_id={CHECKOUT_SESSION_ID}

2. Stripe integration Edge Functions:

supabase/functions/create-checkout/index.ts:
- Receive POST with { tier_id, conference_id, name, email, company }
- Create Stripe Checkout session with:
  - line_items from ticket tier name and price_cents
  - metadata: { tier_id, conference_id, name, email, company }
  - success_url and cancel_url
- Return { url: checkoutUrl }

supabase/functions/stripe-webhook/index.ts:
- Verify webhook signature using STRIPE_WEBHOOK_SECRET
- Handle checkout.session.completed event
- Extract metadata from session.metadata
- Call register_attendee(conference_id, tier_id, name, email, company, payment_intent_id)
- Return 200

Registration success page at src/pages/RegistrationSuccess.tsx:
- Fetch attendee by stripe session ID
- Show confirmation: name, ticket tier, registration_code
- QR code of registration_code (qrcode.react)
```

> Pro tip: Always verify the Stripe webhook signature before processing. In the Deno Edge Function: import Stripe from 'https://esm.sh/stripe@14'; const event = await stripe.webhooks.constructEventAsync(rawBody, signature, STRIPE_WEBHOOK_SECRET). The constructEventAsync (not constructEvent) is required in async Deno environments.

**Expected result:** Paid ticket registration redirects to Stripe Checkout. After payment, the webhook Edge Function calls register_attendee and creates the attendee record. The success page shows the registration code and QR code.

### 5. Build the public schedule page and organizer attendee management

Ask Lovable to build the published conference schedule that attendees browse and the organizer's attendee management dashboard.

```
Build two pages:

1. Public schedule page at src/pages/ConferenceSchedule.tsx:
- Only visible when conference.is_published = true
- Filter bar: track Select, format filter Checkboxes, speaker Select
- Layout: time slots as rows, tracks as columns (same as builder but read-only)
- Each session Card shows: title, speaker photo + name, duration, format Badge, room
- Clicking a session opens a Sheet with full details: abstract, speaker bio, room, time
- 'Add to my schedule' Button (stores in localStorage as a Set of session IDs)
- 'My Schedule' tab showing only bookmarked sessions
- Mobile view: single column with track filter instead of multi-column grid

2. Organizer attendee management at src/pages/AttendeeManagement.tsx:
- Stats row: total registrations, checked in count, revenue (sum of ticket prices)
- DataTable of all attendees: name, email, company, ticket tier Badge, registration code, status, checked_in
- Search by name or email
- Export to CSV Button
- Check-in tab: Input for registration code + 'Check In' Button (same pattern as event-registration-system check-in page)
- Manually mark attendee as checked_in = true and set checked_in_at = now()
- Show real-time checked-in count updating as attendees are processed
```

**Expected result:** The public schedule page shows the full conference schedule with filtering. The organizer attendee dashboard shows registrations, check-in status, and allows manual check-in by code.

## Complete code example

File: `src/lib/validateScheduleConflicts.ts`

```typescript
type Session = {
  id: string
  track_id: string
  speaker_id: string | null
  speaker_name?: string
  room: string | null
  scheduled_start: string | null
  scheduled_end: string | null
  duration_minutes: number
  title: string
}

type ConflictResult =
  | { valid: true }
  | { valid: false; conflict: string; conflicting_session_id: string }

function timeRangesOverlap(
  aStart: string,
  aEnd: string,
  bStart: string,
  bEnd: string
): boolean {
  return aStart < bEnd && aEnd > bStart
}

export function validateScheduleConflicts(
  session: Session,
  proposedTrackId: string,
  proposedStart: string,
  allSessions: Session[]
): ConflictResult {
  const proposedEnd = new Date(
    new Date(proposedStart).getTime() + session.duration_minutes * 60_000
  ).toISOString()

  const otherSessions = allSessions.filter(
    (s) => s.id !== session.id && s.scheduled_start !== null && s.scheduled_end !== null
  )

  // Check speaker conflict
  if (session.speaker_id) {
    const speakerConflict = otherSessions.find(
      (s) =>
        s.speaker_id === session.speaker_id &&
        timeRangesOverlap(
          proposedStart,
          proposedEnd,
          s.scheduled_start!,
          s.scheduled_end!
        )
    )
    if (speakerConflict) {
      return {
        valid: false,
        conflict: `${session.speaker_name ?? 'This speaker'} is already scheduled at this time in another session: '${speakerConflict.title}'`,
        conflicting_session_id: speakerConflict.id,
      }
    }
  }

  // Check room conflict within the same track
  if (session.room) {
    const roomConflict = otherSessions.find(
      (s) =>
        s.track_id === proposedTrackId &&
        s.room === session.room &&
        timeRangesOverlap(
          proposedStart,
          proposedEnd,
          s.scheduled_start!,
          s.scheduled_end!
        )
    )
    if (roomConflict) {
      return {
        valid: false,
        conflict: `Room '${session.room}' is already occupied by '${roomConflict.title}' at this time`,
        conflicting_session_id: roomConflict.id,
      }
    }
  }

  return { valid: true }
}
```

## Common mistakes

- **Running conflict validation only on the client without re-validating on the server** — Two organizers editing the schedule simultaneously can both pass client-side validation and create a conflict because each is working from a stale local state. Fix: Add a validate_session_placement(session_id, track_id, starts_at) Postgres function that checks for conflicts against live database data. Call this before persisting any drag-and-drop change. If validation fails server-side, revert the optimistic update and show the conflict error.
- **Using constructEvent instead of constructEventAsync for Stripe webhooks in Deno** — The synchronous constructEvent uses the Node.js crypto module, which is not available in Deno. It will throw a runtime error in the Edge Function. Fix: Use stripe.webhooks.constructEventAsync(rawBody, signature, webhookSecret) which uses the Web Crypto API available in both Node.js and Deno. Always read the raw request body as text before constructEventAsync: const body = await req.text().
- **Not verifying the Stripe webhook signature** — Without signature verification, anyone who discovers your Edge Function URL can send fake payment completion events and register themselves as paid attendees for free. Fix: Always verify the Stripe-Signature header in the webhook handler using constructEventAsync with your STRIPE_WEBHOOK_SECRET. Reject any request where verification fails with a 400 response.
- **Building the schedule builder without optimistic updates** — Waiting for the Supabase update to complete before showing the drop result makes drag-and-drop feel sluggish. A 200ms round trip creates noticeable lag on every drag. Fix: Apply the session state change locally immediately when a valid drop occurs. Persist to Supabase in the background. On Supabase error, revert the local state and show an error toast. This makes the schedule builder feel instant.
- **Granting direct INSERT on the attendees table instead of using the SECURITY DEFINER function** — Direct INSERT allows clients to bypass capacity checks and create attendee rows without valid payment, enabling free registrations on paid tiers. Fix: Remove the INSERT RLS policy from attendees entirely. All insertions must go through the register_attendee SECURITY DEFINER function, which validates payment intent and decrements remaining_quantity atomically.

## Best practices

- Use a SECURITY DEFINER register_attendee function for all attendee creation. This ensures capacity decrement and attendee insertion are atomic and that no client can bypass payment verification.
- Validate schedule conflicts both client-side (immediate UX feedback) and server-side (via a Postgres validation function before persisting the change). Client-side validation is for speed; server-side is for correctness.
- Store Stripe payment_intent_id on the attendees row to reconcile refunds, disputes, and failed payments in the Stripe dashboard. Query this ID when handling refund events from Stripe webhooks.
- Use dnd-kit's collision detection algorithm appropriate for grid layouts. The rectIntersection or closestCenter strategy works well for multi-column schedule grids. Configure it explicitly rather than using the default.
- Implement optimistic updates for all drag-and-drop operations. Apply state changes locally first, persist to Supabase in background, and revert on failure. This is the difference between a builder that feels fast and one that feels sluggish.
- Always use constructEventAsync (not constructEvent) for Stripe webhook verification in Deno Edge Functions. The async version uses the Web Crypto API instead of Node.js crypto.
- Subscribe to Supabase Realtime on the sessions table in the schedule builder when multiple organizers may edit simultaneously. Broadcast session updates to all connected builders so conflicts from simultaneous edits are visible in real time.
- Scope the public schedule page behind conference.is_published = true. Add a preview link for organizers to see the schedule before publishing. This prevents a half-built schedule from being visible to attendees.

## Frequently asked questions

### Is dnd-kit the right drag-and-drop library for Lovable?

Yes. dnd-kit is the recommended drag-and-drop library for React in 2025. It is accessible, works with touch devices, and has TypeScript support. Ask Lovable to use @dnd-kit/core and @dnd-kit/sortable. Lovable can install npm packages automatically when you reference them in your prompt.

### How do I handle the case where two organizers edit the schedule at the same time?

Subscribe to Supabase Realtime on the sessions table in the schedule builder. When another organizer moves a session, the change broadcasts to all connected builders and the session cards update automatically. Before persisting any drop, call the server-side validate_session_placement function to check against live data, not just the local state which may be stale.

### Why use constructEventAsync instead of constructEvent for Stripe webhooks?

Supabase Edge Functions run on Deno, which does not have Node.js's crypto module. The synchronous constructEvent depends on that module and throws an error in Deno. The constructEventAsync version uses the Web Crypto API (crypto.subtle), which is available in both Deno and Node.js environments.

### How do I handle Stripe refunds when an attendee cancels?

When an organizer cancels an attendee's registration, call a Supabase Edge Function that issues a refund via the Stripe API using the stored stripe_payment_intent_id: await stripe.refunds.create({ payment_intent: attendee.stripe_payment_intent_id }). The Edge Function also sets the attendee status to 'cancelled' and increments remaining_quantity on the ticket tier.

### Can I run the CFP without requiring speakers to create an account?

Yes. The public CFP submission form inserts into cfp_proposals without any auth requirement (the INSERT policy allows anon). Speakers get a confirmation email with a reference code. If you want speakers to later edit their proposals or access a speaker portal, implement magic link auth triggered after their proposal is accepted.

### How do I prevent the schedule from being visible to attendees before it is finalized?

The conference has an is_published boolean and the public schedule page's RLS policy or client-side check only shows schedules for published conferences. Organizers can toggle is_published when the schedule is ready. Until then, the schedule builder is only accessible to authenticated organizers. Add a 'Preview as Attendee' link in the builder that temporarily shows the organizer the public view.

### How large can the conference be before this architecture needs optimization?

The schema and queries in this guide handle conferences up to a few thousand attendees without issues. The main performance consideration is the schedule builder's session query — add an index on sessions(conference_id, track_id) for fast track-based fetches. For attendee management DataTable, add pagination for conferences with more than 500 attendees.

### Where can I get help building additional features like a speaker portal or mobile app?

RapidDev builds full production Lovable applications. If you need a speaker portal with magic link auth, a post-event certificate generator, sponsor management, or a companion mobile check-in app, reach out to discuss a scoped build.

---

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