Skip to main content
RapidDev - Software Development Agency

How to Build a Online Education Platform with Lovable

Build a full LMS in Lovable with courses, lessons, enrollments, and progress tracking backed by Supabase. Paid video lessons are served via signed URLs so only enrolled students can watch. A Realtime progress bar updates live. An enrollment Edge Function gates access and validates payment — all production-ready in 4 hours.

What you'll build

  • courses, lessons, enrollments, and lesson_progress tables with enrollment-gated RLS
  • Course catalog with Cards showing progress percentage for enrolled students
  • Lesson player with signed video URLs from Supabase Storage — inaccessible to non-enrolled users
  • Supabase Realtime subscription that updates a lesson progress bar as students mark steps complete
  • Enrollment gate Edge Function that validates access (free or paid) before granting enrollment
  • Admin course builder with drag-and-drop lesson ordering and video upload to Storage
  • Student dashboard showing enrolled courses, completion percentages, and recently viewed lessons
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Advanced15 min read4–5 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

Build a full LMS in Lovable with courses, lessons, enrollments, and progress tracking backed by Supabase. Paid video lessons are served via signed URLs so only enrolled students can watch. A Realtime progress bar updates live. An enrollment Edge Function gates access and validates payment — all production-ready in 4 hours.

What you're building

A learning management system has two distinct access layers: the public catalog that anyone can browse, and the content layer that only enrolled students can access. The hardest part is making this distinction work reliably at the data level.

Supabase RLS handles the content gate. The lessons table has an RLS SELECT policy that checks: either the lesson is a free preview, or the current user has an active enrollment in the lesson's course. This means even if a student finds a direct lesson URL, they cannot load the lesson data without an enrollment row.

Video files are stored in a private Supabase Storage bucket. The lesson player never loads a direct file URL — it calls an Edge Function that verifies enrollment and generates a 15-minute signed URL. Signed URLs expire automatically so a student cannot share a working video link with non-enrolled users.

Supabase Realtime powers the live progress bar. When a student marks a lesson as complete (updating lesson_progress), a Realtime subscription on the client receives the change and re-calculates the course completion percentage without a page reload.

Final result

A production-ready LMS with enrollment gating, signed video delivery, live progress tracking, and a full admin course builder.

Tech stack

LovableFrontend for students and admins
SupabaseDatabase, Auth, Storage, Realtime
Supabase StorageVideo and material uploads (private bucket)
Supabase Edge FunctionsEnrollment gate and signed URL generation (Deno)
Supabase RealtimeLive progress updates
shadcn/uiProgress, Accordion, Tabs, Card, Sheet components

Prerequisites

  • Lovable Pro account (complex multi-page app with Edge Functions requires significant credits)
  • Supabase project with URL, anon key, and service role key saved to Cloud tab → Secrets
  • A Supabase Storage bucket named 'course-videos' set to private
  • A second Storage bucket named 'course-materials' set to private for PDF handouts
  • Basic understanding of how Supabase Realtime channels work

Build steps

1

Create the LMS schema with enrollment RLS

The schema has four core tables. The enrollment gate in RLS is the most important detail — every lesson query must check for an active enrollment in the parent course.

prompt.txt
1Create a full LMS with Supabase. Set up these tables:
2
3- courses: id (uuid pk), instructor_id (uuid references auth.users), title (text not null), slug (text unique), description (text), thumbnail_url (text), price (numeric default 0, 0 = free), status (text check in ('draft','published'), default 'draft'), student_count (int default 0), lesson_count (int default 0), created_at
4- lessons: id (uuid pk), course_id (uuid references courses), title (text not null), description (text), video_storage_path (text), materials_storage_path (text), duration_seconds (int), sort_order (int not null), is_preview (bool default false, free preview lessons), created_at
5- enrollments: id (uuid pk), student_id (uuid references auth.users), course_id (uuid references courses), enrolled_at, is_active (bool default true), UNIQUE(student_id, course_id)
6- lesson_progress: id (uuid pk), student_id (uuid references auth.users), lesson_id (uuid references lessons), course_id (uuid references courses), completed_at (timestamptz), watched_seconds (int default 0), UNIQUE(student_id, lesson_id)
7
8RLS:
9- courses: anon/authenticated SELECT where status='published', instructor INSERT/UPDATE/DELETE where instructor_id=auth.uid()
10- lessons: authenticated SELECT where is_preview=true OR EXISTS(SELECT 1 FROM enrollments WHERE student_id=auth.uid() AND course_id=lessons.course_id AND is_active=true), instructor INSERT/UPDATE/DELETE
11- enrollments: students SELECT/INSERT their own rows, instructor SELECT for their courses
12- lesson_progress: students SELECT/INSERT/UPDATE their own rows
13
14Create triggers:
15- On enrollments INSERT: increment courses.student_count
16- On enrollments DELETE: decrement courses.student_count
17- On lessons INSERT: increment courses.lesson_count
18- On lessons DELETE: decrement courses.lesson_count

Pro tip: Ask Lovable to also create a view student_course_progress that calculates completion percentage per (student_id, course_id): 100.0 * COUNT(lp.id) / NULLIF(COUNT(l.id), 0) using a join between lessons and lesson_progress. This powers the progress bars throughout the app.

Expected result: All four tables are created with triggers. The lesson RLS policy correctly blocks non-enrolled students. TypeScript types are generated.

2

Build the course catalog with enrollment state

Create the public course catalog. For logged-in students, each course card shows their enrollment status and progress percentage using the student_course_progress view.

prompt.txt
1Build a course catalog page at src/pages/Courses.tsx.
2
3For unauthenticated visitors: show a grid of course Cards with title, thumbnail, description, price (or 'Free' badge), and lesson_count. Each Card has an 'Enroll' Button (clicking prompts login).
4
5For authenticated students:
6- Fetch courses joined with enrollments (LEFT JOIN) for the current user
7- Also fetch student_course_progress for each enrolled course
8- Cards for enrolled courses show a Progress component (0-100%) and a 'Continue Learning' Button that goes to the last viewed lesson
9- Cards for non-enrolled courses show the price and an 'Enroll' Button
10
11Enrollment flow:
12- Free courses: clicking 'Enroll' calls the enroll-student Edge Function with course_id
13- Paid courses: clicking 'Enroll' redirects to a Stripe checkout (or shows a 'Coming Soon' note if Stripe not configured)
14
15Add a 'My Courses' Tabs filter at the top: All / Enrolled / In Progress / Completed
16
17Fetch with: supabase.from('courses').select('*, enrollments(id, is_active), student_course_progress(completion_percentage)').eq('status','published')

Pro tip: For the 'Continue Learning' button, store the last viewed lesson ID in lesson_progress by updated_at. Query the most recent lesson_progress row for the student and that course to determine which lesson to resume.

Expected result: The catalog shows all published courses. Enrolled students see progress bars. Unenrolled students see the enroll button. The Tabs filter works correctly.

3

Build the enrollment gate Edge Function

The enrollment Edge Function validates that a user can enroll in a course (checking price, active account, etc.) before creating the enrollment row. This centralizes enrollment logic in one place.

supabase/functions/enroll-student/index.ts
1// supabase/functions/enroll-student/index.ts
2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
4
5const cors = {
6 'Access-Control-Allow-Origin': '*',
7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
8 'Content-Type': 'application/json',
9}
10
11serve(async (req: Request) => {
12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
13
14 const authHeader = req.headers.get('Authorization')
15 if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
16
17 const { courseId, paymentIntentId } = await req.json()
18
19 const userClient = createClient(
20 Deno.env.get('SUPABASE_URL') ?? '',
21 Deno.env.get('SUPABASE_ANON_KEY') ?? '',
22 { global: { headers: { Authorization: authHeader } } }
23 )
24 const { data: { user } } = await userClient.auth.getUser()
25 if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
26
27 const supabase = createClient(
28 Deno.env.get('SUPABASE_URL') ?? '',
29 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
30 )
31
32 const { data: course } = await supabase.from('courses').select('id, price, status').eq('id', courseId).single()
33 if (!course || course.status !== 'published') {
34 return new Response(JSON.stringify({ error: 'Course not found' }), { status: 404, headers: cors })
35 }
36
37 if (course.price > 0 && !paymentIntentId) {
38 return new Response(JSON.stringify({ error: 'Payment required', requiresPayment: true }), { status: 402, headers: cors })
39 }
40
41 // If paid, verify payment intent with Stripe here
42 // const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
43 // const intent = await stripe.paymentIntents.retrieve(paymentIntentId)
44 // if (intent.status !== 'succeeded') return 402
45
46 const { error } = await supabase
47 .from('enrollments')
48 .upsert({ student_id: user.id, course_id: courseId, is_active: true }, { onConflict: 'student_id,course_id' })
49
50 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
51
52 return new Response(JSON.stringify({ success: true }), { headers: cors })
53})

Expected result: The Edge Function creates an enrollment for free courses. Paid courses without a paymentIntentId receive a 402 with requiresPayment: true. The frontend uses this to redirect to checkout.

4

Build the lesson player with signed video URLs

Create the lesson player page. It calls an Edge Function to get a signed video URL before rendering the video element. Supabase Realtime updates the progress sidebar live.

prompt.txt
1Build a lesson player page at src/pages/LessonPlayer.tsx. Route: /courses/:courseSlug/lessons/:lessonId.
2
3On load:
41. Fetch the lesson from Supabase (RLS will block if not enrolled)
52. If the fetch returns an RLS error, redirect to the course enrollment page
63. Call the get-lesson-video Edge Function with the lessonId to get a signed URL
74. Render a <video> element with the signed URL as src, controls, and playsinline
8
9Layout:
10- Left: video player (wide) above lesson description and materials download button
11- Right sidebar: course lesson list as an Accordion, one AccordionItem per lesson. Current lesson highlighted. Completed lessons show a checkmark icon. Clicking navigates to that lesson.
12- Below video: a Progress component showing course completion percentage (from student_course_progress view)
13- 'Mark as Complete' Button: calls supabase.from('lesson_progress').upsert({ student_id, lesson_id, course_id, completed_at: new Date().toISOString() })
14
15Realtime progress update:
16- After mounting, subscribe to a Realtime channel on the lesson_progress table filtered by student_id=currentUser.id AND course_id=currentCourseId
17- On any INSERT or UPDATE event, refetch the student_course_progress view and update the Progress component
18- Unsubscribe on component unmount
19
20Video progress tracking:
21- On video 'timeupdate' event (throttled to every 10 seconds), upsert lesson_progress with watched_seconds = Math.floor(video.currentTime)

Expected result: The lesson player loads the video via a signed URL. Non-enrolled users are redirected. Clicking 'Mark as Complete' updates the progress bar in real time via Realtime.

5

Build the admin course builder

Create the instructor-facing course builder. Lessons can be reordered by sort_order and videos are uploaded directly to Supabase Storage.

prompt.txt
1Build an admin course builder at src/pages/admin/CourseBuilder.tsx. Route: /admin/courses/:courseId.
2
3Page layout:
4- Course meta editor on the left: title, description, thumbnail upload (public bucket), price, status Select
5- Lesson list on the right: ordered by sort_order
6 - Each lesson row shows: drag handle, sort_order number, title, duration, preview Toggle (is_preview), Edit button, Delete button
7 - Drag-and-drop reordering using HTML5 drag events (no external library needed). On drop, update sort_order for affected lessons in a batch update.
8
9Add Lesson Dialog:
10- Title (Input required)
11- Description (Textarea)
12- Is Preview (Switch)
13- Video Upload: file input (accept video/*). Upload to 'course-videos' at path courses/{courseId}/lessons/{lessonId}/{filename}. Store the storage PATH (not URL) in video_storage_path.
14- Materials Upload: optional PDF file input. Upload to 'course-materials' bucket. Store path in materials_storage_path.
15- Duration: show auto-detected duration from the video element's metadata after upload.
16
17Publish Button: changes course status to 'published'. Show a confirmation Dialog: 'Once published, students can enroll. You can unpublish later.'

Expected result: The course builder shows all lessons in order. Dragging reorders them. Adding a lesson with a video upload stores the file path. Publishing makes the course visible in the catalog.

Complete code

supabase/functions/enroll-student/index.ts
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
3
4const cors = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
7 'Content-Type': 'application/json',
8}
9
10serve(async (req: Request) => {
11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })
12
13 const authHeader = req.headers.get('Authorization')
14 if (!authHeader) {
15 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
16 }
17
18 const { courseId } = await req.json()
19
20 const userClient = createClient(
21 Deno.env.get('SUPABASE_URL') ?? '',
22 Deno.env.get('SUPABASE_ANON_KEY') ?? '',
23 { global: { headers: { Authorization: authHeader } } }
24 )
25 const { data: { user } } = await userClient.auth.getUser()
26 if (!user) {
27 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
28 }
29
30 const supabase = createClient(
31 Deno.env.get('SUPABASE_URL') ?? '',
32 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
33 )
34
35 const { data: course } = await supabase
36 .from('courses')
37 .select('id, price, status')
38 .eq('id', courseId)
39 .single()
40
41 if (!course || course.status !== 'published') {
42 return new Response(JSON.stringify({ error: 'Course not found' }), { status: 404, headers: cors })
43 }
44
45 if (course.price > 0) {
46 return new Response(JSON.stringify({ error: 'Payment required', requiresPayment: true, price: course.price }), { status: 402, headers: cors })
47 }
48
49 const { error } = await supabase
50 .from('enrollments')
51 .upsert(
52 { student_id: user.id, course_id: courseId, is_active: true },
53 { onConflict: 'student_id,course_id' }
54 )
55
56 if (error) {
57 return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
58 }
59
60 return new Response(JSON.stringify({ success: true }), { headers: cors })
61})

Customization ideas

Certificate of completion

When a student's lesson_progress count equals the course lesson_count (100% complete), generate a PDF certificate using a Supabase Edge Function. The certificate includes the student's name, course title, and a unique certificate ID. Store the PDF in Storage and add a certificates table for verification by ID.

Quiz between lessons

Add a quizzes table with questions and multiple-choice answers linked to specific lessons. After completing a lesson, a quiz Dialog appears before the next lesson unlocks. Pass/fail is stored in quiz_results. Use the online-quiz-app guide as the foundation for the quiz engine.

Discussion threads per lesson

Add a lesson_comments table where enrolled students ask questions and the instructor answers. Display comments below the video player in a threaded Accordion. Use Supabase Realtime to show new comments instantly without page reload.

Cohort-based enrollment windows

Add a cohorts table with start_date and end_date. Enrollments reference a cohort. Students can only enroll during the open window. The enrollment Edge Function checks if a cohort is active before granting access. Instructors can create and manage cohorts from the admin panel.

Instructor analytics dashboard

Build an admin analytics page showing: total enrollments over time (Recharts area chart), lesson completion funnel (how many students finish each lesson), average watch percentage per lesson, and student churn (enrolled but no activity in 7 days). All metrics come from aggregating enrollments and lesson_progress.

Common pitfalls

Pitfall: Using a public Storage bucket for course videos

How to avoid: Keep the course-videos bucket private. Always generate signed URLs via an Edge Function that verifies enrollment first. Set signed URL expiry to 15-30 minutes — enough to watch a lesson but not to share indefinitely.

Pitfall: Not unsubscribing from Realtime channels on component unmount

How to avoid: In the LessonPlayer component's useEffect cleanup function, call channel.unsubscribe(). Ask Lovable: 'Make sure the Realtime channel is unsubscribed in the useEffect cleanup function in LessonPlayer.tsx'.

Pitfall: Storing video URLs instead of storage paths in the database

How to avoid: Store only the storage path (e.g. courses/abc123/lessons/def456/intro.mp4) in video_storage_path. Generate the signed URL at runtime using this path via the Edge Function.

Pitfall: Loading all lessons on every page render without RLS optimization

How to avoid: Check enrollment status once at the top level (a single EXISTS query) and pass it as a flag. If enrolled, fetch lessons with the service role key in the Edge Function. If not enrolled, fetch only is_preview=true lessons with the anon key.

Best practices

  • Gate video access in the Edge Function, not just in the frontend. RLS blocks the lesson data, but the signed URL generation is the final content gate. Both layers must verify enrollment independently.
  • Store video_storage_path and materials_storage_path as paths, not URLs. Paths are permanent; signed URLs and public URLs can change or expire.
  • Throttle video progress saves to every 10 seconds. Saving on every timeupdate event creates dozens of database writes per minute per student, which is expensive and unnecessary.
  • Use Supabase Realtime broadcast mode (not database changes) for high-frequency updates like video playback position. Database-change Realtime is best for less frequent events like lesson completion.
  • Set a maximum free enrollment count per course per day to prevent abuse. Add an enrollments_today check in the enrollment Edge Function that counts recent enrollments for the IP address.
  • Index lesson_progress on (student_id, course_id) to make the completion percentage calculation fast. Without this index, the student_course_progress view is slow on users with many courses.
  • Design the UI to gracefully degrade when Realtime is disconnected. The Progress component should work from the last-fetched database value even when the Realtime subscription fails.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an LMS with Supabase. I have courses, lessons, enrollments, and lesson_progress tables. The lesson_progress table has completed_at and watched_seconds. I want a SQL view student_course_progress that returns student_id, course_id, total_lessons (count of lessons for the course), completed_lessons (count of lesson_progress rows with completed_at not null for this student), completion_percentage (0-100), and last_activity_at (max updated_at from lesson_progress). Write the CREATE VIEW SQL statement.

Lovable Prompt

Add a student dashboard at /dashboard. Show three sections: 1) In Progress: courses with 1-99% completion as horizontal Cards with a Progress bar and 'Continue' Button. 2) Completed: courses with 100% completion with a 'Download Certificate' Button (disabled if no certificate generated yet). 3) Explore: 3 recommended courses not yet enrolled in, chosen by matching categories from enrolled courses. All data from the student_course_progress view and the courses table.

Build Prompt

In Supabase, create an Edge Function get-lesson-video that accepts a lessonId, verifies the calling user has an active enrollment in the lesson's course using the service role key, and returns a 15-minute signed URL for the video_storage_path from the course-videos bucket. If the lesson is is_preview=true, skip the enrollment check and return the signed URL for any authenticated user.

Frequently asked questions

How large can the video files be?

Supabase Storage supports individual files up to 50MB on the free plan and up to 5GB on paid plans (with the correct storage settings). For course videos, use a video compression tool to target 720p at around 1GB per hour of content before uploading. For very large libraries, consider using Bunny CDN or Cloudflare Stream for video delivery and storing only the CDN URL in Supabase.

How do signed URLs work for video streaming?

A signed URL is a temporary URL that grants access to a private file for a limited time. When the lesson player loads, it calls your Edge Function, which verifies enrollment and calls supabase.storage.from('course-videos').createSignedUrl(path, 900) to get a URL valid for 15 minutes. The video element uses this URL as its src. After 15 minutes the URL expires, but the video buffered in the browser continues playing normally.

Can I use YouTube or Vimeo for videos instead of Supabase Storage?

Yes, but you lose access control. YouTube and Vimeo URLs are shareable. If access control matters (paid courses), use a private Vimeo account with domain restrictions or store videos in Supabase Storage. If your course is free, YouTube embeds work fine — just store the YouTube video ID in the lesson record and render an iframe player instead of a video element.

How does the completion percentage work across multiple devices?

The lesson_progress table stores completion data in Supabase, not in localStorage. A student can switch from laptop to phone and see the same progress because it is loaded from the database on every session. The Realtime subscription updates the UI when a completion event fires from any device on the same account.

Can students re-watch completed lessons?

Yes. Completing a lesson just sets completed_at in lesson_progress — it does not block access. Students can re-watch any lesson they have completed. The completion state is indicated by the checkmark in the lesson sidebar. To reset progress, add an 'Uncomplete' button that sets completed_at = null.

How do I handle lesson ordering when adding new lessons to an existing course?

New lessons are appended at the end by default (sort_order = max current sort_order + 1). The drag-and-drop in the course builder lets instructors reorder them. When a lesson is deleted, the remaining sort_order values may have gaps — ask Lovable to add a trigger that resequences sort_order (1, 2, 3...) after any lesson deletion in the same course.

Can I get help adding Stripe payments to gate course enrollment?

RapidDev specializes in Lovable apps with Stripe integration including one-time course purchases, subscription access, and coupon code support. The enrollment Edge Function in this guide has a comment showing exactly where Stripe verification plugs in. Reach out if you need the full payment integration built.

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.