# How to Add Virtual Workshops to Your App — Copy-Paste Prompts Included

- Tool: App Features
- Last updated: July 2026

## TL;DR

A virtual workshop feature needs five pieces working together: Daily.co for live WebRTC video, Stripe to gate room access behind payment, Supabase Realtime for live chat and polls, a Supabase Edge Function to generate Daily room tokens server-side, and cloud recording for post-workshop replay. Expect 6–12 hours with Lovable or V0. Running costs start at $10–25/month for small workshops and scale to $500–1,500/month at 10,000 users as Daily.co participant-minutes dominate.

## What a Virtual Workshop Feature Actually Is

A virtual workshop feature is not a Zoom replacement embedded in your app — it is a structured, gated live experience where attendees pay, join a video room, interact through chat and polls, and leave with a recording. The video layer (Daily.co or LiveKit) is the commodity part; the product decisions are the payment gate that prevents unauthorized access, the host controls that let you run a real session, and the post-workshop replay that extends the value of each event. Builders consistently underestimate how tightly these pieces must be coupled: a Stripe webhook delay that lets users join before payment clears, or a room token generated client-side that exposes your Daily API key, are both critical failures that ship on first attempts.

## Anatomy of the Feature

Six components — the video layer is solved infrastructure; the payment gate and token generation are where first builds fail.

- **Video conferencing layer** (service): Daily.co Prebuilt UI or the daily-js React SDK handles WebRTC video, audio, screenshare, and bandwidth adaptation. Daily manages TURN/STUN servers so you never configure WebRTC infrastructure. The Prebuilt UI option embeds a complete call UI in one React component; the custom SDK lets you design the host dashboard from scratch.
- **Host control dashboard** (ui): A React component using react-use-daily hooks reads participant state (camera on/off, speaking, raised hand) and exposes host-only controls: mute all, spotlight speaker, lock room to new joins, remove participant. Role-based rendering shows this panel only when user.isOwner === true from the Daily participant object.
- **Chat and Q&A system** (backend): Supabase Realtime channel broadcasts chat messages to all registered attendees. A separate workshop_polls table uses Realtime postgres_changes to push live vote counts. Q&A entries in a workshop_chat table carry an is_question bool and upvote_count int — upvote via Supabase RPC to avoid race conditions on the count column.
- **Registration and payment gate** (service): Stripe Checkout creates a payment session for the workshop price. On checkout.session.completed webhook, the Supabase Edge Function marks the registration payment_status as 'confirmed'. A separate token-generation Edge Function checks registration status before issuing a Daily room token — attendees with unconfirmed payment receive a 403.
- **Recording and playback** (service): Daily built-in cloud recording stores session video to Daily's S3. The recording_ready webhook posts the recording URL to your Supabase Edge Function, which saves it to the workshops table and triggers a Resend email to all confirmed attendees. React Player or Video.js renders the recording on the post-workshop page.
- **Workshop data model** (data): Supabase tables: workshops (host, schedule, price, capacity, Daily room name), registrations (payment status, join time), workshop_chat (messages and Q&A), workshop_polls (question + jsonb options and responses). RLS restricts chat and polls to confirmed registered attendees only.

## Data model

Run this in the Supabase SQL editor. It creates all four tables with RLS — attendees only see chat and polls for workshops they are registered and paid for.

```sql
create table public.workshops (
  id uuid primary key default gen_random_uuid(),
  host_id uuid references auth.users(id) on delete cascade not null,
  title text not null,
  description text,
  scheduled_at timestamptz not null,
  duration_min int not null default 60,
  price_cents int not null default 0,
  max_attendees int not null default 50,
  daily_room_name text,
  stripe_product_id text,
  recording_url text,
  status text not null default 'scheduled' check (status in ('scheduled','live','ended','cancelled')),
  created_at timestamptz not null default now()
);

create table public.registrations (
  id uuid primary key default gen_random_uuid(),
  workshop_id uuid references public.workshops(id) on delete cascade not null,
  user_id uuid references auth.users(id) on delete cascade not null,
  stripe_payment_intent_id text,
  payment_status text not null default 'pending' check (payment_status in ('pending','confirmed','refunded')),
  joined_at timestamptz,
  created_at timestamptz not null default now(),
  unique(workshop_id, user_id)
);

create table public.workshop_chat (
  id uuid primary key default gen_random_uuid(),
  workshop_id uuid references public.workshops(id) on delete cascade not null,
  user_id uuid references auth.users(id) on delete cascade not null,
  message text not null,
  is_question bool not null default false,
  upvote_count int not null default 0,
  created_at timestamptz not null default now()
);

create table public.workshop_polls (
  id uuid primary key default gen_random_uuid(),
  workshop_id uuid references public.workshops(id) on delete cascade not null,
  question text not null,
  options jsonb not null,
  responses jsonb not null default '{}',
  is_active bool not null default false,
  created_at timestamptz not null default now()
);

alter table public.workshops enable row level security;
alter table public.registrations enable row level security;
alter table public.workshop_chat enable row level security;
alter table public.workshop_polls enable row level security;

-- Workshops: hosts see all their own; attendees see scheduled/live/ended
create policy "Host manages own workshops"
  on public.workshops for all
  using (auth.uid() = host_id);

create policy "Anyone can view public workshops"
  on public.workshops for select
  using (status in ('scheduled', 'live', 'ended'));

-- Registrations: each user sees their own; host sees registrations for their workshops
create policy "User sees own registrations"
  on public.registrations for select
  using (auth.uid() = user_id);

create policy "Host sees workshop registrations"
  on public.registrations for select
  using (
    auth.uid() = (select host_id from public.workshops where id = workshop_id)
  );

create policy "User can register"
  on public.registrations for insert
  with check (auth.uid() = user_id);

-- Chat: only confirmed attendees or host can read/write
create policy "Confirmed attendees and host can chat"
  on public.workshop_chat for select
  using (
    auth.uid() in (
      select user_id from public.registrations
      where workshop_id = workshop_chat.workshop_id and payment_status = 'confirmed'
    )
    or auth.uid() = (select host_id from public.workshops where id = workshop_chat.workshop_id)
  );

create policy "Confirmed attendees and host can send messages"
  on public.workshop_chat for insert
  with check (
    auth.uid() in (
      select user_id from public.registrations
      where workshop_id = workshop_chat.workshop_id and payment_status = 'confirmed'
    )
    or auth.uid() = (select host_id from public.workshops where id = workshop_chat.workshop_id)
  );

-- Polls: visible to confirmed attendees and host
create policy "Confirmed attendees and host can view polls"
  on public.workshop_polls for select
  using (
    auth.uid() in (
      select user_id from public.registrations
      where workshop_id = workshop_polls.workshop_id and payment_status = 'confirmed'
    )
    or auth.uid() = (select host_id from public.workshops where id = workshop_polls.workshop_id)
  );

create index registrations_workshop_status_idx
  on public.registrations (workshop_id, payment_status);

create index chat_workshop_created_idx
  on public.workshop_chat (workshop_id, created_at);
```

The registrations_workshop_status_idx speeds up the RLS sub-select that checks confirmed payment on every chat read — critical once workshops have 50+ attendees sending rapid messages.

## Build paths

### Lovable — fit 3/10, 6–10 hours

Lovable handles the Stripe payment gate and Supabase Realtime chat well; the Daily.co SDK integration works but must be built and tested entirely on the published URL, never in the preview iframe.

1. Create a new Lovable project, connect Lovable Cloud to provision Supabase, and add DAILY_API_KEY and STRIPE_SECRET_KEY to the Secrets panel under the Cloud tab
2. Paste the prompt below; let Agent Mode build the registration page, payment flow, and workshop room page as separate routes
3. Open Supabase Edge Functions in the Cloud tab and verify the token generation and Stripe webhook functions deployed correctly
4. Click Publish and open the live URL in a desktop browser to test host controls, then join from a second browser tab to test attendee view — never test WebRTC in the Lovable preview iframe
5. Test the Stripe payment flow using Stripe test card 4242 4242 4242 4242 before switching to live mode

Starter prompt:

```
Build a virtual workshop feature. Use Daily.co for live video (daily-js React SDK). Workshop creation page: form with title, description, scheduled date/time, duration, price in USD, max attendees. Store in Supabase 'workshops' table. Registration page: show workshop details and a Stripe Checkout button (create checkout session via Supabase Edge Function); on payment success redirect to a /workshop/:id/room route. Room page: embed Daily.co call UI using @daily-co/daily-react; generate Daily room token in a Supabase Edge Function (verify registration payment_status = 'confirmed' before issuing token — return 403 if not confirmed); show host controls panel (mute all, remove participant, spotlight) only if current user is the workshop host. Supabase Realtime sidebar: live chat messages and Q&A tab (is_question flag with upvote button); live polls created by host with real-time result bars using Recharts. Recording: start recording when host clicks 'Start Recording'; store recording_url from Daily webhook in workshops table; show playback page post-workshop. Stripe webhook handler uses constructEventAsync() for Deno. Handle edge cases: attendee joins before host, workshop at capacity, host cancels workshop (trigger Stripe refund via Edge Function and email all registrants via Resend).
```

Limitations:

- Daily.co WebRTC getUserMedia is blocked in the Lovable preview iframe — all video testing must happen on the published URL
- Complex real-time features (chat + polls + video simultaneously) carry high Lovable 'looping' risk — if the AI gets stuck, duplicate the project and restart from the last stable checkpoint
- Lovable Cloud's managed Supabase is not visible in the Supabase Dashboard — use the Logs panel in the Cloud tab to debug Edge Function errors

### V0 — fit 3/10, 8–12 hours

V0 generates the cleanest Next.js registration pages and post-workshop playback UI; the Daily.co React SDK integration requires careful Server Component / Client Component separation and manual Supabase and Stripe environment variable setup.

1. Prompt V0 with the spec below to generate the workshop listing, registration, room, and playback pages
2. Add DAILY_API_KEY, STRIPE_SECRET_KEY, NEXT_PUBLIC_SUPABASE_URL, and SUPABASE_ANON_KEY in the V0 Vars panel
3. Run the SQL schema from this page in the Supabase SQL editor to create workshops, registrations, workshop_chat, and workshop_polls tables
4. Create the Daily room token API route and the Stripe webhook route manually as Next.js Route Handlers under app/api/
5. Deploy to production via V0 Publish and test the full payment-to-room flow; the V0 sandbox preview cannot access the camera or microphone

Starter prompt:

```
Build a virtual workshop platform in Next.js (App Router). Pages: /workshops (Server Component, ISR 60s, lists upcoming workshops from Supabase); /workshops/:id (registration page with price display and Stripe Checkout button — triggers /api/workshops/checkout Route Handler); /workshops/:id/room (Client Component — embeds Daily.co React SDK via @daily-co/daily-react; fetches Daily room token from /api/workshops/token which verifies Supabase registration payment_status before calling Daily REST API); /workshops/:id/replay (shows recording_url from Supabase using React Player). Route Handlers: /api/workshops/checkout creates Stripe Checkout session; /api/workshops/token verifies confirmed registration then calls Daily API to generate meeting token with owner:true for host; /api/webhooks/stripe handles checkout.session.completed to set payment_status='confirmed' and checkout.session.expired to handle failures; /api/webhooks/daily handles recording_ready to save recording_url. Supabase Realtime in Client Component: subscribe to workshop_chat channel for live chat; subscribe to workshop_polls channel for live poll results displayed as Recharts BarChart. Host controls using daily.updateParticipant() and daily.startRecording(). Handle edge cases: Vercel cold start delaying token response (add 3-second retry on the client), user not signed in redirected to login with return_url, workshop at max capacity shows 'sold out' state.
```

Limitations:

- V0 sandbox preview cannot access camera or microphone — deploy to Vercel and test on the production URL
- V0 may generate localStorage state management for workshop data which crashes on SSR — all workshop state must live in Server Component props or Supabase, not localStorage
- Daily.co's daily-js package is not SSR-compatible — wrap the room component in next/dynamic with ssr: false

### Flutterflow — fit 3/10, 8–14 hours

FlutterFlow gives native camera and mic access on mobile, making it the right choice when your workshop attendees are primarily on phones or tablets; screenshare from mobile is constrained by iOS/Android OS limits.

1. Add daily_flutter as a Custom Package in FlutterFlow project settings; add flutter_stripe and supabase_flutter packages
2. Create the workshop registration page using FlutterFlow's built-in Stripe payment action (configure with your publishable key in Settings)
3. Add a Custom Action for Daily room token fetching — call your Supabase Edge Function URL with the workshop_id and user auth token
4. Build the workshop room page using a Custom Widget wrapping DailyCall from daily_flutter; bind camera/mic toggle buttons to Daily Custom Actions
5. Enable camera and microphone permissions in Settings → Permissions with descriptive usage strings required by iOS App Store review

Limitations:

- daily_flutter is a community package with limited FlutterFlow visual binding — host controls require Custom Actions on the Pro plan ($70/mo)
- iOS screenshare requires ReplayKit extension which cannot be added through FlutterFlow — hosts who need screenshare should join from desktop web
- Supabase Realtime for live chat requires Custom Action integration; the built-in Supabase node does not expose Realtime channels

### Custom — fit 5/10, 3–5 weeks

Custom development is the right call when workshops are your core product — breakout rooms, whiteboard collaboration, 50+ attendee SFU architecture, and LMS integration all require full control over the video layer.

1. Evaluate SFU: Daily.co for fastest time-to-production; LiveKit self-hosted on Fly.io or Cloudflare Workers for cost optimization at scale; Agora for lowest latency in Asia-Pacific markets
2. Build the token service as a standalone Next.js API layer with Redis caching of room states to avoid redundant Daily API calls
3. Implement breakout room orchestration by creating Daily sub-rooms and issuing scoped tokens that restrict participants to their assigned room
4. Add LMS integration (Teachable, Thinkific) via their REST APIs to sync workshop completions and issue certificates
5. Load test with k6 simulating 100 concurrent attendees joining simultaneously to validate token endpoint and Supabase Realtime under real conditions

Limitations:

- LiveKit self-hosted requires Kubernetes or Fly.io operations expertise — adds 1–2 weeks vs using Daily.co managed infrastructure
- Breakout rooms double your Daily.co participant-minute costs — model this carefully before choosing self-hosted

## Gotchas

- **Daily.co getUserMedia blocked in preview iframe** — The Lovable preview iframe and V0 sandbox both run in a sandboxed iframe that does not set allow='camera; microphone' — WebRTC calls to getUserMedia throw NotAllowedError silently, and the video element stays black. Developers assume the camera integration is broken when the app is actually fine. Fix: Test all video features exclusively on the published HTTPS URL. In Lovable, scan the mobile preview QR code or click Publish. In V0, deploy to production first. Add a visible 'open in full window' button on the room page as a permanent escape hatch.
- **Daily room token generated client-side exposes API key** — AI tools frequently generate the Daily room token by calling the Daily REST API directly from a React component using fetch(). This exposes DAILY_API_KEY in the browser's network tab and allows anyone to create unlimited Daily rooms on your account. Fix: Generate all Daily tokens in a Supabase Edge Function that reads DAILY_API_KEY from Deno.env.get(). The client sends only the workshop_id and its Supabase auth token. The Edge Function verifies payment_status before calling Daily and never returns the raw API key.
- **Stripe webhook fires after user already tried to join** — Stripe webhook delivery averages 1–3 seconds after payment completion. If you redirect users to the room immediately on Stripe's success_url, they hit the token endpoint before the webhook has confirmed payment — the Edge Function sees payment_status = 'pending' and returns 403. The user thinks payment failed. Fix: On the room page, poll the registration payment_status endpoint up to 5 times with 1-second intervals before showing an error. Alternatively, use Stripe's synchronous PaymentIntent confirmation (not Checkout Session) so payment status is confirmed before the redirect.
- **Supabase Realtime drops during long workshops** — Supabase Realtime connections idle-timeout after extended periods (default 30 minutes), and mobile clients reconnect when switching between WiFi and cellular. Without reconnection logic, the chat panel silently stops receiving messages for some attendees mid-workshop. Fix: Implement Supabase channel reconnection: listen for the CHANNEL_ERROR and TIMED_OUT subscription states and call channel.subscribe() again with exponential backoff. Show a 'Reconnecting to chat...' banner in the UI so attendees know the state.
- **Max attendees not enforced — room fills past capacity** — AI tools generate the max_attendees check client-side before the join button or in the registration form — not at token issuance. A user who registered before capacity was reached can still join after the room is full, and concurrent registrations can exceed capacity in a race. Fix: Enforce capacity in the Supabase Edge Function that issues Daily tokens: SELECT COUNT(*) FROM registrations WHERE workshop_id = $1 AND payment_status = 'confirmed' and compare against max_attendees. Return 409 Conflict if at capacity. Add a unique partial index on registrations to cap concurrent confirmed rows.

## Best practices

- Create the Daily room when the workshop is scheduled, not at join time — cold room creation takes 500–2,000ms and delays the first attendee's entry
- Issue Daily meeting tokens with exp set to 2 hours maximum — a leaked token with no expiry gives permanent access to your room
- Send a calendar invite (.ics attachment) in the registration confirmation email using Resend — it is the single highest-impact UX improvement for workshop attendance rates
- Always enforce payment confirmation in the Edge Function, never client-side — client-side checks can be bypassed by anyone with browser dev tools
- Subscribe to a single Supabase Realtime channel per workshop_id for both chat and polls — avoid per-row subscriptions that multiply connection count with message volume
- Store the Daily recording_url in your database immediately when the recording_ready webhook fires — Daily's hosted URLs expire after 30 days without re-hosting
- Show a participant count and session duration timer in the host dashboard — hosts lose track of time and attendee drop-off is invisible without it
- Test Stripe webhook verification (constructEventAsync in Deno Edge Functions) before launch — a missing raw body causes HMAC signature failure and silently drops payment confirmations

## Frequently asked questions

### Do I need to build my own video infrastructure?

No. Daily.co handles WebRTC, TURN/STUN servers, bandwidth adaptation, and recording — you call their REST API to create rooms and issue tokens, and embed their React SDK. The free tier gives you 10,000 participant-minutes per month, which covers roughly three 60-minute workshops with 30 attendees before any cost.

### How do I prevent uninvited attendees from joining?

Generate Daily meeting tokens in a Supabase Edge Function that first verifies the user has a confirmed registration (payment_status = 'confirmed') for that workshop. The token has a short expiry (2 hours maximum). Without a valid token, Daily will not admit anyone to the room — the room URL alone is not enough to join.

### Can I record workshops and sell them as replays?

Yes. Daily's cloud recording stores the session to S3 and fires a recording_ready webhook with the URL. Save that URL to your workshops table and build a /workshops/:id/replay page using React Player. To sell replay access, apply the same Stripe payment gate you use for live attendance — check purchase status before displaying the player.

### What is the cost per workshop attendee?

Daily.co charges $0.00099/participant-minute (approx) after the free tier. A 60-minute workshop with 30 attendees consumes 1,800 participant-minutes, costing roughly $1.78 in video costs. At 10 workshops per month with 30 attendees each, that is about $18/month in Daily fees — well below typical ticket revenue.

### How do I add a Q&A with audience upvoting?

Add an is_question bool and upvote_count int to your workshop_chat table. Display Q&A entries sorted by upvote_count descending. Handle upvotes via a Supabase RPC function that increments the count atomically to avoid race conditions — do not do a read-then-write from the client. Subscribe to postgres_changes on the workshop_chat table to push upvote count updates to all attendees in real time.

### Can I run workshops on both mobile and desktop?

Daily.co's Prebuilt UI works in desktop browsers and Android Chrome. iOS Safari supports WebRTC since iOS 14.3. Mobile screenshare is limited — iOS only allows screenshare through a ReplayKit extension, so hosts presenting slides should join from desktop. For a native mobile experience, the FlutterFlow path with daily_flutter is the better choice.

### How do I handle refunds when I cancel a workshop?

Trigger a Stripe refund via the Stripe API in a Supabase Edge Function when the host sets workshop status to 'cancelled'. Loop through all confirmed registrations, call Stripe's refund endpoint for each payment_intent_id, and mark registration payment_status as 'refunded'. Send a Resend email to each registrant confirming the refund. Full refund processing typically appears in 5–10 business days on the attendee's statement.

### What is the difference between a webinar tool and a custom workshop feature?

Webinar tools (Zoom Webinars, Riverside) are separate products your users must context-switch to. A custom workshop feature lives inside your app, behind your auth and payment system, with your branding. You keep 100% of ticket revenue (minus Stripe fees), you own the attendee data, and you can build features unique to your audience — like polls that feed into your product's data model or recordings gated behind your subscription.

---

Source: https://www.rapidevelopers.com/app-features/virtual-workshops
© RapidDev — https://www.rapidevelopers.com/app-features/virtual-workshops
