Skip to main content
RapidDev - Software Development Agency
Lovable PromptsB2C SaaSIntermediate

Build an Online Education Platform in Lovable

A course platform with public catalog, Stripe one-time enrollment checkout, enrolled-only lesson video/PDF access gated by Storage RLS, per-student progress tracking, drip content scheduling, and an instructor analytics dashboard.

Time to MVP

1-2 days

Credits

~150-250 credits for full chain

Difficulty

Intermediate

Cloud features

4

TL;DR

The one-paragraph version before you dive in.

Paste the starter prompt into Lovable Agent Mode and get a working course platform: public catalog, Stripe-paid enrollments, enrolled-only lesson access with video gating via Storage RLS, progress tracking, and an instructor dashboard. Full build takes 1-2 days with the eight follow-up prompts. Expected credit burn: 150-250 on a Pro $25/mo plan.

Setup checklist

Complete these steps in Lovable before pasting the starter prompt — takes about 5 minutes.

Cloud tab settings

Database

Stores courses, lessons, enrollments, progress, and the stripe_events idempotency table.

  1. 1Click the + button next to Preview in the top toolbar to open the Cloud tab
  2. 2Click Database — you'll see an empty Table Editor
  3. 3Leave it empty — the starter prompt creates all six tables with migrations

Auth

Email+password and Google OAuth for students. Instructor role stored in app_metadata so it's signed into the JWT.

  1. 1Cloud tab → Users & Auth
  2. 2Under Sign-in methods, enable Email (already on) and Google OAuth if you want social login
  3. 3Leave 'Allow new users to sign up' ON so students can self-register
  4. 4After your first instructor account is created, open that user's row and edit app_metadata to {"role": "instructor"}

Storage

Two buckets: lesson-videos (private, enrolled-only RLS) and course-thumbnails (public-read). This is the critical piece that prevents non-enrolled users from streaming videos.

  1. 1Cloud tab → Storage
  2. 2Click Create bucket, name it 'lesson-videos', leave Public OFF
  3. 3Click Create bucket again, name it 'course-thumbnails', toggle Public ON
  4. 4The starter prompt will add the enrolled-only RLS policy to lesson-videos automatically

Edge Functions

Four functions handle enrollment checkout, Stripe webhook, progress tracking, and drip email — all run as Deno on Lovable Cloud.

  1. 1No manual setup needed — the starter prompt scaffolds the function files
  2. 2After running the starter prompt, open Cloud tab → Edge Functions to confirm the four functions appeared
  3. 3You'll add secrets in the next step before deploying

Secrets (Cloud tab → Secrets)

STRIPE_SECRET_KEY

Purpose: Used by create-enrollment-checkout to create Stripe Checkout sessions and by stripe-webhook to construct events

Where to get it: Go to https://dashboard.stripe.com/apikeys — copy the Secret key (starts sk_test_ for test mode)

STRIPE_WEBHOOK_SECRET

Purpose: Verifies the webhook payload signature via constructEventAsync — required to prevent spoofed webhook calls

Where to get it: Stripe Dashboard → Developers → Webhooks → click your endpoint → Signing secret (starts whsec_)

RESEND_API_KEY

Purpose: Sends welcome emails, drip lesson unlock emails, and course completion notifications

Where to get it: Go to https://resend.com/api-keys — create a new key with 'Sending access'

OPENAI_API_KEY

Purpose: Optional: GPT-5.4 generates lesson summaries and quiz questions from transcripts (instructor follow-up)

Where to get it: Go to https://platform.openai.com/api-keys — only needed if you add the quiz-generation follow-up

Preflight checklist

  • You're in a fresh Lovable project (Vite + React + TypeScript + Tailwind + shadcn/ui already scaffolded)
  • You're on Pro $25/mo — the full chain burns 150-250 credits and Free caps at ~30/mo
  • You have a Stripe account in test mode with a product created (or let the prompt scaffold it)
  • You've added STRIPE_SECRET_KEY and RESEND_API_KEY to Cloud tab → Secrets before testing enrollment
  • Stripe webhook endpoint must point to your deployed URL (not preview) — you can only test enrollment after clicking Publish

The starter prompt — paste this first

Copy this. Paste it into Lovable Agent Mode (the default chat at the bottom-left of the editor). Hit send.

lovable-agent-mode.txt
~80-110 credits
Build an online education platform (course + lesson system). Stack: React + Vite + TypeScript + Tailwind + shadcn/ui (already scaffolded), React Router v6 for routes, Supabase JS client for all data access against Lovable Cloud.

## Database schema (create one migration)

Create six tables in the public schema:

1. `profiles` — id uuid PK references auth.users.id, full_name text, email text not null, role text default 'student' check role in ('student','instructor','admin'), created_at timestamptz default now(). RLS: own read/write; instructors read profiles of students enrolled in their courses via a SECURITY DEFINER function.

2. `courses` — id uuid PK default gen_random_uuid(), instructor_id uuid not null references auth.users.id, title text not null, slug text unique not null, description text, thumbnail_path text (Storage: course-thumbnails), price_cents int not null check (price_cents >= 0), stripe_price_id text, status text default 'draft' check in ('draft','published','archived'), created_at timestamptz default now(). RLS: public-read WHERE status='published'; instructor writes own; admin writes all.

3. `lessons` — id uuid PK, course_id uuid not null references courses(id) on delete cascade, position int not null, title text not null, video_path text (Storage path in lesson-videos bucket), pdf_path text, transcript text, duration_seconds int, drip_unlock_days int default 0 (0 = unlocked immediately at enrollment), created_at timestamptz default now(). UNIQUE(course_id, position). RLS: enrolled students read via SECURITY DEFINER function is_enrolled(course_id); instructors read own; public can read position/title/duration only via a view.

4. `enrollments` — id uuid PK, student_id uuid not null references auth.users.id, course_id uuid not null references courses(id), status text default 'pending' check in ('pending','active','refunded','expired'), enrolled_at timestamptz, stripe_payment_intent_id text unique, amount_paid_cents int. UNIQUE(student_id, course_id). RLS: student reads own; instructor reads enrollments for own courses; webhook writes via SECURITY DEFINER apply_enrollment() function only.

5. `progress` — id uuid PK, student_id uuid references auth.users.id, lesson_id uuid references lessons(id), completed_at timestamptz, watched_seconds int default 0. UNIQUE(student_id, lesson_id). RLS: own read/write only; instructors read via course ownership.

6. `stripe_events` — id uuid PK, stripe_event_id text unique not null, event_type text not null, received_at timestamptz default now(), processed_at timestamptz. RLS: no client access; webhook-only writes.

Enable RLS on all six tables. Create two SECURITY DEFINER plpgsql functions:
- `is_enrolled(p_course_id uuid) RETURNS boolean` — returns EXISTS(SELECT 1 FROM enrollments WHERE student_id = auth.uid() AND course_id = p_course_id AND status = 'active')
- `apply_enrollment(p_student_id uuid, p_course_id uuid, p_pi text, p_amount int) RETURNS void` — upserts enrollments row with status='active', enrolled_at=now()

Create Storage bucket policies:
- lesson-videos: SELECT only for authenticated users WHERE EXISTS(SELECT 1 FROM lessons l JOIN enrollments e ON e.course_id = l.course_id WHERE l.video_path = name AND e.student_id = auth.uid() AND e.status = 'active')
- course-thumbnails: public-read

Seed: 1 instructor profile + 1 published course + 3 lessons (positions 1-3, drip_unlock_days 0/0/7) + 1 active enrollment for the instructor as a test student.

## Three layouts

Create:
- `src/layouts/PublicLayout.tsx` — header with logo + Browse Courses link + Sign In button / user avatar dropdown; footer. For /courses routes.
- `src/layouts/AppLayout.tsx` — sidebar with My Courses, Browse, Profile + main content area. For /learn routes (authenticated).
- `src/layouts/InstructorLayout.tsx` — sidebar with My Courses, Analytics, New Course + main content. For /instructor routes.

## Pages

Create these routes in src/App.tsx:
- `/` (Landing.tsx) — hero section with Browse Courses CTA
- `/courses` (Catalog.tsx) — card grid of published courses, search by title, filter by price range
- `/courses/:slug` (CourseDetail.tsx) — hero, description, instructor bio, lesson list (titles + durations, video paths hidden), enroll CTA that calls create-enrollment-checkout Edge Function
- `/checkout/success` (CheckoutSuccess.tsx) — post-Stripe redirect; polls enrollments table until status='active' then redirects to /learn/:slug
- `/learn` (MyCourses.tsx) — grid of enrolled courses with progress percentage
- `/learn/:slug` (CoursePlayer.tsx) — two-column: left sidebar LessonSidebar + right main area LessonPlayer; gated by EnrollmentGuard
- `/instructor` (InstructorDashboard.tsx) — table of your courses: enrollments count, revenue, completion rate
- `/instructor/courses/:id/edit` (CourseEditor.tsx) — course fields + sortable lesson list + video upload per lesson

## Components

Build these reusable components:
- `AuthGuard` — wraps /learn and /instructor; redirects to /login if no session
- `EnrollmentGuard` — wraps /learn/:slug routes; calls is_enrolled(), redirects to /courses/:slug with a 'purchase to unlock' param if not enrolled
- `InstructorRoleGate` — wraps /instructor routes; checks JWT app_metadata.role='instructor'
- `CourseCard` — 16:9 thumbnail + title + instructor name + price + total duration
- `LessonSidebar` — ordered lesson list; completed lessons show CheckCircle (emerald), locked drip lessons show Lock icon with 'Available in N days', current lesson highlighted
- `LessonPlayer` — video player (use native HTML5 video element for v1) with title, transcript below, Mark Complete button that calls track-progress Edge Function
- `ProgressBar` — thin emerald bar showing percent of lessons completed

## Edge Functions to scaffold (Deno)

1. `supabase/functions/create-enrollment-checkout/index.ts` — accepts {course_id} from authenticated user, creates Stripe Checkout session in 'payment' mode with price=course.price_cents, success_url=/checkout/success?session_id={CHECKOUT_SESSION_ID}, cancel_url=/courses/${slug}, metadata={course_id, student_id=auth.uid()}

2. `supabase/functions/stripe-webhook/index.ts` — reads raw body via `const body = await req.text()`, validates with `await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'))`, checks stripe_events for idempotency, on checkout.session.completed calls apply_enrollment() with values from event.data.object.metadata

3. `supabase/functions/track-progress/index.ts` — accepts {lesson_id, watched_seconds, completed}, upserts progress row using ON CONFLICT (student_id, lesson_id) DO UPDATE SET watched_seconds = GREATEST(...), completed_at = COALESCE(...)

## Styling
Light + dark mode via shadcn ThemeProvider. Primary color emerald-600. CourseCard: thumbnail with rounded-lg, price tag badge bottom-right. LessonSidebar: 280px fixed width, locked lessons gray text. LessonPlayer: video at 16:9 aspect ratio, sticky at top on mobile.

Generate the migration first, then the three layouts, then the pages in the order above, then the Edge Functions. Stop after each major section.

What this prompt generates

  • Creates a SQL migration with 6 tables, 2 SECURITY DEFINER functions (is_enrolled, apply_enrollment), and enrolled-only Storage RLS on lesson-videos bucket
  • Scaffolds three layouts (PublicLayout, AppLayout, InstructorLayout) and 8 page components wired to React Router
  • Builds AuthGuard, EnrollmentGuard, and InstructorRoleGate route protection components
  • Scaffolds 3 Edge Functions: enrollment checkout creator, raw-body Stripe webhook, and progress tracker
  • Seeds 1 course with 3 lessons including one drip-locked lesson so you can see the lock icon in preview

Paste into: Lovable Agent Mode (the default chat at the bottom-left of the editor)

Expected output

What Lovable will generate after the starter prompt runs successfully.

Files
supabase/migrations/0001_education_schema.sql

6 tables + RLS + 2 SECURITY DEFINER functions + 2 Storage bucket policies + seed data

src/layouts/PublicLayout.tsx

Header + footer shell for public /courses routes

src/layouts/AppLayout.tsx

Authenticated sidebar shell for /learn routes

src/layouts/InstructorLayout.tsx

Instructor sidebar shell for /instructor routes

src/components/AuthGuard.tsx

Redirects to /login if no active session

src/components/EnrollmentGuard.tsx

Calls is_enrolled(), redirects to course detail if not enrolled

src/components/InstructorRoleGate.tsx

Checks JWT app_metadata.role='instructor'

src/components/CourseCard.tsx

16:9 thumbnail + title + price + duration card

src/components/LessonSidebar.tsx

Ordered lesson list with completion/lock states

src/components/LessonPlayer.tsx

HTML5 video + transcript + Mark Complete button

src/components/ProgressBar.tsx

Emerald progress bar with percent completed

src/pages/Landing.tsx

Hero section with Browse Courses CTA

src/pages/Catalog.tsx

Card grid of published courses with search and filter

src/pages/CourseDetail.tsx

Course hero, lesson list preview, Enroll CTA

src/pages/CheckoutSuccess.tsx

Post-Stripe redirect; polls enrollment then navigates to /learn

src/pages/MyCourses.tsx

Grid of enrolled courses with progress bars

src/pages/CoursePlayer.tsx

Two-column lesson player gated by EnrollmentGuard

src/pages/InstructorDashboard.tsx

Course table with enrollment count and revenue

src/pages/CourseEditor.tsx

Course fields + sortable lesson list + video upload

supabase/functions/create-enrollment-checkout/index.ts

Creates Stripe Checkout session in payment mode

supabase/functions/stripe-webhook/index.ts

Raw-body webhook with constructEventAsync + idempotency

supabase/functions/track-progress/index.ts

Upserts progress rows with GREATEST watched_seconds logic

Routes
/

Landing page with hero and Browse Courses CTA

/courses

Public catalog with search and price filter

/courses/:slug

Course detail with lesson preview and Enroll button

/checkout/success

Post-Stripe redirect page; polls for active enrollment

/learn

My Courses dashboard for enrolled students

/learn/:slug

Two-column lesson player gated by EnrollmentGuard

/instructor

Instructor dashboard with course analytics

/instructor/courses/:id/edit

Course editor with lesson list and video upload

DB Tables
profiles
ColumnType
iduuid primary key references auth.users.id
full_nametext
emailtext not null
roletext default 'student'

RLS: Own read/write; instructor reads via course enrollment join

courses
ColumnType
iduuid primary key default gen_random_uuid()
instructor_iduuid not null references auth.users.id
slugtext unique not null
price_centsint not null check >= 0
statustext default 'draft'

RLS: Public-read WHERE status='published'; instructor writes own only

lessons
ColumnType
iduuid primary key
course_iduuid not null references courses(id) on delete cascade
positionint not null
video_pathtext
drip_unlock_daysint default 0

RLS: Full row read gated by is_enrolled(course_id) SECURITY DEFINER function

enrollments
ColumnType
iduuid primary key
student_iduuid not null references auth.users.id
course_iduuid not null references courses(id)
statustext default 'pending'
stripe_payment_intent_idtext unique

RLS: Student reads own; instructor reads for own courses; webhook writes via apply_enrollment() SECURITY DEFINER

progress
ColumnType
iduuid primary key
student_iduuid references auth.users.id
lesson_iduuid references lessons(id)
watched_secondsint default 0
completed_attimestamptz

RLS: Own row read/write only; UNIQUE(student_id, lesson_id)

stripe_events
ColumnType
iduuid primary key
stripe_event_idtext unique not null
event_typetext not null
processed_attimestamptz

RLS: No client access — webhook-only writes for idempotency dedup

Components
EnrollmentGuard

Calls is_enrolled(), redirects to course detail if not active

LessonSidebar

Lesson list with completion checkmarks and drip lock states

LessonPlayer

HTML5 video player with transcript and progress tracking

CourseCard

Catalog card with thumbnail, price, and enrollment state

ProgressBar

Emerald completion bar shown on My Courses page

Follow-up prompts

Paste these into Agent Mode one by one, in order, after the starter prompt finishes.

1

Add Stripe checkout + webhook with raw body and idempotency

Functional Stripe checkout flow with raw-body webhook, idempotency dedup, and post-payment redirect

~80 credits
prompt
Wire the Stripe enrollment flow end to end.

In create-enrollment-checkout/index.ts:
- Verify the caller is authenticated via supabase.auth.getUser()
- Look up the course by course_id to get price_cents and slug
- Create a Stripe Checkout session: mode='payment', line_items with price_data.unit_amount=price_cents, success_url=/checkout/success?session_id={CHECKOUT_SESSION_ID}, cancel_url=/courses/${slug}, metadata={course_id, student_id}
- Return {url: session.url} to the client

In stripe-webhook/index.ts:
- Read raw body FIRST: const body = await req.text()
- Get signature: const sig = req.headers.get('stripe-signature')
- Verify: const event = await stripe.webhooks.constructEventAsync(body, sig, Deno.env.get('STRIPE_WEBHOOK_SECRET'))
- Check idempotency: query stripe_events WHERE stripe_event_id = event.id; if exists return 200
- Insert stripe_events row
- On checkout.session.completed: extract course_id and student_id from event.data.object.metadata, call apply_enrollment() RPC
- Return 200

In CheckoutSuccess.tsx:
- Extract session_id from URL params
- Poll supabase.from('enrollments').select() with status='active' every 2 seconds for up to 20 seconds
- Show spinner; once enrollment is active, navigate to /learn/${slug}

When to use: Immediately after the starter prompt finishes — before any test payment

2

Lock down lesson-video Storage RLS

Enrolled-only video access — students cannot stream videos for courses they never purchased

~30 credits
prompt
Audit and tighten the lesson-videos Storage bucket RLS.

In Supabase SQL editor, run:
DROP POLICY IF EXISTS enrolled_students_read_video ON storage.objects;
CREATE POLICY enrolled_students_read_video ON storage.objects
  FOR SELECT TO authenticated
  USING (
    bucket_id = 'lesson-videos'
    AND EXISTS (
      SELECT 1 FROM lessons l
      JOIN enrollments e ON e.course_id = l.course_id
      WHERE l.video_path = name
        AND e.student_id = auth.uid()
        AND e.status = 'active'
    )
  );

Update LessonPlayer to fetch video via signed URL instead of public URL:
const { data } = await supabase.storage.from('lesson-videos').createSignedUrl(lesson.video_path, 3600);
Set video.src = data.signedUrl.

Test: sign in as a student enrolled in Course A only, then try to call createSignedUrl for a Course B video path — it must return null/error, not a valid URL.

When to use: Immediately after the webhook works — before publishing the course publicly

3

Add per-student progress tracking

Real-time completion tracking per lesson and overall course progress bar

~50 credits
prompt
Wire lesson progress tracking.

In track-progress/index.ts, accept {lesson_id, watched_seconds, completed: bool} from the authenticated user.
Upsert the progress row:
INSERT INTO progress (student_id, lesson_id, watched_seconds, completed_at)
VALUES ($student_id, $lesson_id, $watched_seconds, $completed_at)
ON CONFLICT (student_id, lesson_id) DO UPDATE
  SET watched_seconds = GREATEST(progress.watched_seconds, EXCLUDED.watched_seconds),
      completed_at = COALESCE(progress.completed_at, EXCLUDED.completed_at);

In LessonPlayer, fire track-progress every 30 seconds while the video plays, and once more on pause/end with the final watched_seconds. Pass completed=true when watched_seconds >= lesson.duration_seconds * 0.9.

In LessonSidebar, load the student's progress rows via supabase.from('progress').select() and show CheckCircle on completed lessons. Compute overall course completion percent and pass to ProgressBar.

When to use: After basic lesson playback works

4

Add drip content scheduling

Drip content: locked lessons with countdown timers, preventing access before the scheduled unlock date

~40 credits
prompt
Implement drip unlocking for lessons with drip_unlock_days > 0.

In LessonSidebar, for each lesson:
- If drip_unlock_days === 0, it's unlocked immediately
- Otherwise, compute unlock_at = enrollment.enrolled_at + drip_unlock_days days
- If unlock_at > now(), render the lesson row with a Lock icon and 'Unlocks in N days' subtitle (gray text)
- If unlock_at <= now(), render as accessible

In EnrollmentGuard (or a separate drip check inside CoursePlayer), before rendering the lesson player for a specific lesson_id:
- Fetch the lesson's drip_unlock_days and the student's enrollment.enrolled_at
- If still locked, show a centered card: 'This lesson unlocks on [date]. Come back then.' with a countdown

Do NOT render the video path or PDF path for locked lessons in any API response — confirm the lesson query only returns video_path if the drip window has passed.

When to use: For cohort-based courses or sequential skill-building programs

5

Add instructor analytics dashboard

Course analytics with enrollment count, revenue, completion rate, and per-lesson dropout funnel

~70 credits
prompt
Build the instructor analytics page at /instructor.

Create a Postgres view or RPC that returns per-course metrics for the authenticated instructor:
- enrollment_count: COUNT(*) from enrollments WHERE course_id = course.id AND status='active'
- revenue_cents: SUM(amount_paid_cents) from enrollments
- completion_rate: percent of enrolled students who completed all lessons
- per_lesson_dropout: for each lesson, count of students who completed it (for funnel view)

In InstructorDashboard.tsx, call this RPC and render:
- A summary row per course: title, enrollments, revenue (formatted as currency), completion rate (as %)
- Click a course row to expand a lesson-by-lesson funnel chart (shadcn/ui BarChart or Recharts BarChart)

Add a date-range filter (Last 7 days / 30 days / All time) that filters enrollment.enrolled_at.

All queries must gate on instructor_id = auth.uid() — an instructor must never see another instructor's metrics.

When to use: Once you have 10+ real enrollments

6

Add Resend welcome and drip emails

Welcome email on enrollment, lesson-unlock drip notifications, and course completion congratulations

~60 credits
prompt
Wire email notifications via Resend.

Create a send-drip-email Edge Function. Configure RESEND_API_KEY in Cloud tab → Secrets.

Add a Postgres trigger on enrollments INSERT (status='active') that calls pg_net.http_post to send-drip-email with action='welcome' and the student's email + course title. The welcome email should include a direct link to /learn/${slug} and a one-line overview of the course.

For drip unlocks: update send-drip-email to also accept action='unlock' with lesson_id. Set up a pg_cron job that runs hourly, queries lessons with drip_unlock_days > 0, joins with enrollments WHERE enrolled_at + drip_unlock_days * interval '1 day' < now(), and sends a Resend email for each lesson newly unlocked for each student (use a sent_drip_emails table to track which (student_id, lesson_id) combos have already been notified).

Add a completion email: trigger on progress table when completed_at IS NOT NULL and it's the final lesson in the course — send a 'Congratulations, you completed {course title}!' email via Resend.

When to use: For engagement and retention once you have 5+ active students

7

Move video storage to Cloudflare R2 to eliminate egress costs

Zero-egress video delivery via Cloudflare R2, with enrollment check maintained in your Edge Function

~80 credits
prompt
Migrate lesson video storage from Supabase Storage to Cloudflare R2 to eliminate egress fees.

Create a new R2 bucket in Cloudflare Dashboard. Add these secrets to Cloud tab → Secrets: R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET_NAME, R2_PUBLIC_DOMAIN (your R2 custom domain or public endpoint).

Create upload-video-to-r2 Edge Function using the S3-compatible API (Cloudflare R2 implements S3 protocol). Accept multipart form data with the video file, upload to R2 at key `${courseId}/${lessonId}/${filename}`, return the R2 key.

Update the lessons.video_path column to store R2 keys instead of Supabase Storage paths.

Update LessonPlayer: instead of supabase.storage.createSignedUrl, call a get-video-signed-url Edge Function that verifies enrollment via is_enrolled(), generates a 1-hour presigned R2 URL using AWS4 signature scheme, and returns it. Player loads via this signed URL.

Delete the lesson-videos Supabase Storage bucket policy once all videos are on R2.

When to use: When your first popular course starts generating significant video traffic — Supabase Storage egress at scale adds up fast

8

Add quiz and assignment per lesson

MCQ and true/false quizzes per lesson with auto-grading, attempt history, and pass/fail gating

~120 credits
prompt
Add quizzes to lessons for assessment-driven courses.

Create two new tables:
- quizzes: id, lesson_id, title, pass_threshold_percent (default 70)
- quiz_questions: id, quiz_id, question_text, question_type (check in 'mcq','true_false'), options jsonb (array of {text, is_correct}), position
- quiz_attempts: id, student_id, quiz_id, score_percent, passed bool, submitted_at, answers jsonb

RLS: students read/write own quiz_attempts; instructors read all attempts for own courses; quiz_questions are readable by enrolled students only (via is_enrolled()).

In CourseEditor, add a Quiz tab per lesson. InstructorRoleGate ensures only the course owner edits it.

In LessonPlayer, after the video completes (or if the lesson has no video), render a Quiz component if quiz exists. MCQ questions shown one at a time with radio buttons. On submit, compute score client-side, INSERT quiz_attempts row, show result (pass/fail) with correct answers highlighted.

If quiz passed and it was the last lesson, trigger the completion email.

When to use: For certification or skill-verification courses where assessment is required

Common errors

Real error strings you'll see. Find yours, paste the fix prompt.

SubtleCryptoProvider cannot be used in a synchronous context. Use await constructEventAsync(...) instead of constructEvent(...)

Lovable scaffolded the stripe-webhook Edge Function using the synchronous constructEvent, which fails in Deno because Deno's WebCrypto is async. Every Stripe-integrated Lovable app hits this on first webhook test.

Fix — paste into Lovable Agent Mode
In stripe-webhook/index.ts, replace constructEvent with await constructEventAsync. Read body BEFORE verification: const body = await req.text(); const event = await stripe.webhooks.constructEventAsync(body, signature, Deno.env.get('STRIPE_WEBHOOK_SECRET')). Do NOT call req.json() anywhere before this — JSON-parsing destroys the raw body and breaks signature verification.
Student paid for Course A but can stream videos from Course B they never bought

Your lesson-videos Storage policy was scaffolded as bucket-level (bucket_id = 'lesson-videos' AND auth.uid() IS NOT NULL) without joining through the enrollments table. Any authenticated user can generate a signed URL for any video path.

Fix — paste into Lovable Agent Mode
Drop the current storage.objects policy on lesson-videos and recreate: CREATE POLICY enrolled_students_read_video ON storage.objects FOR SELECT TO authenticated USING (bucket_id = 'lesson-videos' AND EXISTS (SELECT 1 FROM lessons l JOIN enrollments e ON e.course_id = l.course_id WHERE l.video_path = name AND e.student_id = auth.uid() AND e.status = 'active')). Test: sign in as a student enrolled only in Course A and call createSignedUrl for a Course B video path — must return error.
Stripe integration doesn't work in preview — payment never completes

Lovable's Cloud connectors and Edge Functions only run on the deployed app, not inside the in-editor preview iframe. The preview has no public URL for Stripe to redirect to.

Manual fix

This is expected behavior. Click Publish in the top-right toolbar to deploy to your lovable.app subdomain. Go to Stripe Dashboard → Developers → Webhooks → Add endpoint, set the URL to https://[your-project].lovable.app/api/stripe-webhook and select checkout.session.completed. Test the full flow with test card 4242 4242 4242 4242 on the deployed URL.

After payment, /checkout/success shows spinner forever and never navigates to /learn

Either the webhook never fired (Stripe endpoint URL wrong or webhook secret mismatch) or the webhook fired but apply_enrollment() failed due to RLS — the webhook context doesn't bypass RLS unless the function is SECURITY DEFINER.

Fix — paste into Lovable Agent Mode
Stripe Dashboard → Developers → Webhooks → click your endpoint → Recent deliveries. If 4xx or 0 deliveries: fix URL and redeploy. If 200 but no enrollment row: confirm apply_enrollment() is defined as LANGUAGE plpgsql SECURITY DEFINER. Test: SELECT apply_enrollment('test-student-uuid', 'test-course-uuid', 'pi_test', 2999) from SQL editor as anon — should succeed and insert a row.
Video player loads but shows 'Failed to fetch' after 30 seconds

You generated a signed URL with 60-second expiry. The student paused, rewound, or had a slow connection and the URL expired before the browser finished loading the video segment.

Fix — paste into Lovable Agent Mode
Generate signed URLs with at least 1 hour expiry: supabase.storage.from('lesson-videos').createSignedUrl(lesson.video_path, 3600). For videos longer than 30 minutes, add a background interval in LessonPlayer that refreshes the signed URL when remaining time drops below 5 minutes, then updates the video.src without interrupting playback.
Progress saves at 100% then reverts to 0% when student opens the lesson on another device

Your track-progress function uses INSERT instead of UPSERT — the second device's first progress event conflicts on the UNIQUE(student_id, lesson_id) constraint, the error is swallowed, and the UI falls back to 0.

Fix — paste into Lovable Agent Mode
In track-progress Edge Function, use UPSERT: INSERT INTO progress (student_id, lesson_id, watched_seconds, completed_at) VALUES ($1,$2,$3,$4) ON CONFLICT (student_id, lesson_id) DO UPDATE SET watched_seconds = GREATEST(progress.watched_seconds, EXCLUDED.watched_seconds), completed_at = COALESCE(progress.completed_at, EXCLUDED.completed_at). Keep the maximum watched_seconds and the first completed_at across all devices.

Cost reality

What this build actually costs — no surprises on your card.

Recommended Lovable plan

Pro $25/mo with one top-up budgeted. Stripe webhook iteration, Storage RLS debugging, and lesson player integration each burn 30-80 credits. Free plan's ~30 credit monthly cap won't cover this build.

Monthly run cost breakdown

~150-250 credits (starter ~80-110, follow-ups 1-4 ~200, follow-ups 5-8 add ~330 more if all done — most creators ship after follow-up #4) total
ItemCost
Lovable Pro

Drop to Free plan after build is done — the app runs on Cloud

$25/mo
Supabase / Lovable Cloud

Free tier handles 1K+ enrollments easily; 5GB egress for video is the limit (see scaling note)

$0/mo at MVP
Stripe

The dominant ongoing cost; at 100 x $99 enrollments you pay ~$320/mo in fees

2.9% + 30¢ per enrollment
Resend

Covers welcome + drip + completion for first ~1,000 students

$0/mo up to 3K emails
Custom domain

Via Cloud tab → Publish → custom domain on Pro plan

$10-15/yr

Scaling notes: Storage egress is the #1 cost driver, not DB or auth. Supabase Free gives 5GB egress total — at 100MB per video lesson, that's ~50 lesson views total before egress costs kick in. A single course going viral will exhaust this in days. Move video to Cloudflare R2 ($0 egress) or Mux (video CDN with adaptive bitrate, ~$0.005/min streamed) once you hit 50+ active students. The R2 migration follow-up is included in this kit for exactly this scenario.

Production checklist

Steps to take before you share the URL with real users.

Domain & SSL

  • Connect custom domain

    Click Publish (top-right) → Settings → Custom domain. Add CNAME record pointing to your Lovable subdomain. SSL auto-provisions.

  • Update Stripe webhook URL to custom domain

    Stripe Dashboard → Developers → Webhooks → edit endpoint URL from lovable.app subdomain to your custom domain

  • Update OAuth redirect URLs

    If using Google OAuth: Cloud tab → Users & Auth → Google provider → update Redirect URL to https://yourdomain.com/auth/callback

Stripe Production Keys

  • Switch from test to live Stripe keys

    Stripe Dashboard → toggle Live mode → API keys → copy sk_live_* and signing secret. Update STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET in Cloud tab → Secrets.

  • Create a new webhook endpoint in Live mode

    Stripe Dashboard (live mode) → Developers → Webhooks → Add endpoint → your production URL → select checkout.session.completed

Video Storage

  • Set Supabase Storage egress budget alert

    Supabase Dashboard → Billing → Usage alerts. Set alert at 4GB egress so you get warning before hitting 5GB Free limit.

  • Plan R2 migration before launch if expecting >100 students

    Run the 'Move video to Cloudflare R2' follow-up prompt before course goes public rather than scrambling after egress bill arrives.

Monitoring

  • Set up Stripe webhook delivery monitoring

    Stripe Dashboard → Developers → Webhooks → your endpoint → enable email alerts on failure. Failed webhooks = lost enrollments.

  • Check Cloud tab → Logs weekly

    Cloud tab → Logs → Edge Functions. Look for failed track-progress or apply_enrollment calls and fix before they accumulate.

Frequently asked questions

How do I stop students from sharing video URLs with friends?

The enrolled-only Storage RLS policy and signed URLs together handle this. When LessonPlayer calls createSignedUrl, Supabase checks the enrolled-only policy at sign time — only enrolled students get a valid signed URL. Even if a student copies the signed URL and shares it, that URL expires after 1 hour. The critical thing is that your lesson-videos bucket must NOT be public, and the policy must join through the enrollments table (not just check auth.uid() IS NOT NULL).

What does it actually cost when one of my courses goes viral?

The cost spike comes from video egress, not from Lovable or Stripe. Supabase Free gives 5GB egress total. A 100MB lesson video watched by 100 students = 10GB — double the Free tier limit. At that point you're looking at Supabase Pro ($25/mo, 250GB bandwidth) or migrating to Cloudflare R2 ($0 egress). Plan for the R2 migration before launch if you expect meaningful traffic. The R2 follow-up prompt in this kit handles the migration.

Should I use Supabase Storage or move video to Mux or R2 from day one?

Start with Supabase Storage — it's zero config and the enrolled-only RLS is easy to test. Switch to Cloudflare R2 (zero egress cost) once you hit 50+ active students, or Mux (video CDN with adaptive bitrate, closed captions, analytics) if video quality is core to your brand. R2 migration takes 2-3 hours with the follow-up prompt. Mux adds ~$0.005/min streamed but handles the video player, transcoding, and thumbnail generation for you.

Can I do recurring subscriptions instead of per-course payments?

Yes, but the subscription-system kit is better suited for that. Instead of Stripe Checkout in payment mode, use subscription mode with a price ID. The enrollment logic is different: instead of apply_enrollment on checkout.session.completed, you gate on invoice.paid for the recurring period. The membership-site kit in this library covers exactly this pattern — combine it with the course platform starter for a hybrid per-course + subscription model.

How do I issue certificates of completion?

Add a certificate-generation follow-up after the quiz kit is in place. When a student completes all lessons (and passes any quizzes), trigger a generate-certificate Edge Function that uses @pdf-lib to render a certificate PDF with the student's name, course title, and completion date. Store the PDF in a public certificates bucket (no enrollment RLS needed — certificates are meant to be shared), and send the download link via Resend. You can add a /certificate/:uuid public page that renders the certificate for sharing on LinkedIn.

Does the Stripe integration handle EU VAT?

Stripe itself handles VAT collection if you enable Stripe Tax ($0.50 per transaction once you have >$500/mo in volume). In your create-enrollment-checkout Edge Function, add automatic_tax: {enabled: true} to the Checkout session params. Stripe calculates and collects VAT based on the student's IP location. You still need to configure your tax registrations in Stripe Dashboard → Tax → Registrations for each jurisdiction. For VAT invoicing, Stripe Invoicing (separate from Checkout) generates compliant VAT invoices automatically.

When does it make sense to hire RapidDev instead of staying on Teachable?

If your build outgrows this prompt kit and you need custom architecture, RapidDev builds production-grade Lovable apps at $13K-$25K — book a free 30-minute consultation at rapidevelopers.com.

Need a production-grade version?

RapidDev builds production-grade Lovable apps at $13K–$25K. Book a free 30-minute consultation.

Book a free consultation

30-min call. No commitment.

Want this built for you?

We ship production apps at a fixed price — $13K–$25K, 6–10 weeks, source code yours. You've seen what it takes; we do it every week.

Get a fixed-price quote

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.