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
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
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.
1Create a full LMS with Supabase. Set up these tables:23- 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_at4- 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_at5- 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)78RLS: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/DELETE11- enrollments: students SELECT/INSERT their own rows, instructor SELECT for their courses12- lesson_progress: students SELECT/INSERT/UPDATE their own rows1314Create triggers:15- On enrollments INSERT: increment courses.student_count16- On enrollments DELETE: decrement courses.student_count17- On lessons INSERT: increment courses.lesson_count18- On lessons DELETE: decrement courses.lesson_countPro 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.
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.
1Build a course catalog page at src/pages/Courses.tsx.23For 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).45For authenticated students:6- Fetch courses joined with enrollments (LEFT JOIN) for the current user7- Also fetch student_course_progress for each enrolled course8- Cards for enrolled courses show a Progress component (0-100%) and a 'Continue Learning' Button that goes to the last viewed lesson9- Cards for non-enrolled courses show the price and an 'Enroll' Button1011Enrollment flow:12- Free courses: clicking 'Enroll' calls the enroll-student Edge Function with course_id13- Paid courses: clicking 'Enroll' redirects to a Stripe checkout (or shows a 'Coming Soon' note if Stripe not configured)1415Add a 'My Courses' Tabs filter at the top: All / Enrolled / In Progress / Completed1617Fetch 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.
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.
1// supabase/functions/enroll-student/index.ts2import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'3import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'45const cors = {6 'Access-Control-Allow-Origin': '*',7 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',8 'Content-Type': 'application/json',9}1011serve(async (req: Request) => {12 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1314 const authHeader = req.headers.get('Authorization')15 if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })1617 const { courseId, paymentIntentId } = await req.json()1819 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 })2627 const supabase = createClient(28 Deno.env.get('SUPABASE_URL') ?? '',29 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''30 )3132 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 }3637 if (course.price > 0 && !paymentIntentId) {38 return new Response(JSON.stringify({ error: 'Payment required', requiresPayment: true }), { status: 402, headers: cors })39 }4041 // If paid, verify payment intent with Stripe here42 // const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)43 // const intent = await stripe.paymentIntents.retrieve(paymentIntentId)44 // if (intent.status !== 'succeeded') return 4024546 const { error } = await supabase47 .from('enrollments')48 .upsert({ student_id: user.id, course_id: courseId, is_active: true }, { onConflict: 'student_id,course_id' })4950 if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })5152 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.
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.
1Build a lesson player page at src/pages/LessonPlayer.tsx. Route: /courses/:courseSlug/lessons/:lessonId.23On 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 page63. Call the get-lesson-video Edge Function with the lessonId to get a signed URL74. Render a <video> element with the signed URL as src, controls, and playsinline89Layout:10- Left: video player (wide) above lesson description and materials download button11- 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() })1415Realtime progress update:16- After mounting, subscribe to a Realtime channel on the lesson_progress table filtered by student_id=currentUser.id AND course_id=currentCourseId17- On any INSERT or UPDATE event, refetch the student_course_progress view and update the Progress component18- Unsubscribe on component unmount1920Video 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.
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.
1Build an admin course builder at src/pages/admin/CourseBuilder.tsx. Route: /admin/courses/:courseId.23Page layout:4- Course meta editor on the left: title, description, thumbnail upload (public bucket), price, status Select5- Lesson list on the right: ordered by sort_order6 - Each lesson row shows: drag handle, sort_order number, title, duration, preview Toggle (is_preview), Edit button, Delete button7 - Drag-and-drop reordering using HTML5 drag events (no external library needed). On drop, update sort_order for affected lessons in a batch update.89Add 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.1617Publish 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
1import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'2import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'34const cors = {5 'Access-Control-Allow-Origin': '*',6 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',7 'Content-Type': 'application/json',8}910serve(async (req: Request) => {11 if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })1213 const authHeader = req.headers.get('Authorization')14 if (!authHeader) {15 return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })16 }1718 const { courseId } = await req.json()1920 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 }2930 const supabase = createClient(31 Deno.env.get('SUPABASE_URL') ?? '',32 Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''33 )3435 const { data: course } = await supabase36 .from('courses')37 .select('id, price, status')38 .eq('id', courseId)39 .single()4041 if (!course || course.status !== 'published') {42 return new Response(JSON.stringify({ error: 'Course not found' }), { status: 404, headers: cors })43 }4445 if (course.price > 0) {46 return new Response(JSON.stringify({ error: 'Payment required', requiresPayment: true, price: course.price }), { status: 402, headers: cors })47 }4849 const { error } = await supabase50 .from('enrollments')51 .upsert(52 { student_id: user.id, course_id: courseId, is_active: true },53 { onConflict: 'student_id,course_id' }54 )5556 if (error) {57 return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })58 }5960 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation