# How to Build an Online Education Platform with Lovable

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

## 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.

## Before you start

- 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

## Step-by-step guide

### 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.

```
Create a full LMS with Supabase. Set up these tables:

- 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
- 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
- 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)
- 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)

RLS:
- courses: anon/authenticated SELECT where status='published', instructor INSERT/UPDATE/DELETE where instructor_id=auth.uid()
- 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
- enrollments: students SELECT/INSERT their own rows, instructor SELECT for their courses
- lesson_progress: students SELECT/INSERT/UPDATE their own rows

Create triggers:
- On enrollments INSERT: increment courses.student_count
- On enrollments DELETE: decrement courses.student_count
- On lessons INSERT: increment courses.lesson_count
- 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.

```
Build a course catalog page at src/pages/Courses.tsx.

For 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).

For authenticated students:
- Fetch courses joined with enrollments (LEFT JOIN) for the current user
- Also fetch student_course_progress for each enrolled course
- Cards for enrolled courses show a Progress component (0-100%) and a 'Continue Learning' Button that goes to the last viewed lesson
- Cards for non-enrolled courses show the price and an 'Enroll' Button

Enrollment flow:
- Free courses: clicking 'Enroll' calls the enroll-student Edge Function with course_id
- Paid courses: clicking 'Enroll' redirects to a Stripe checkout (or shows a 'Coming Soon' note if Stripe not configured)

Add a 'My Courses' Tabs filter at the top: All / Enrolled / In Progress / Completed

Fetch 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
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const { courseId, paymentIntentId } = await req.json()

  const userClient = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await userClient.auth.getUser()
  if (!user) return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: course } = await supabase.from('courses').select('id, price, status').eq('id', courseId).single()
  if (!course || course.status !== 'published') {
    return new Response(JSON.stringify({ error: 'Course not found' }), { status: 404, headers: cors })
  }

  if (course.price > 0 && !paymentIntentId) {
    return new Response(JSON.stringify({ error: 'Payment required', requiresPayment: true }), { status: 402, headers: cors })
  }

  // If paid, verify payment intent with Stripe here
  // const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!)
  // const intent = await stripe.paymentIntents.retrieve(paymentIntentId)
  // if (intent.status !== 'succeeded') return 402

  const { error } = await supabase
    .from('enrollments')
    .upsert({ student_id: user.id, course_id: courseId, is_active: true }, { onConflict: 'student_id,course_id' })

  if (error) return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })

  return new Response(JSON.stringify({ success: true }), { headers: cors })
})
```

**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.

```
Build a lesson player page at src/pages/LessonPlayer.tsx. Route: /courses/:courseSlug/lessons/:lessonId.

On load:
1. Fetch the lesson from Supabase (RLS will block if not enrolled)
2. If the fetch returns an RLS error, redirect to the course enrollment page
3. Call the get-lesson-video Edge Function with the lessonId to get a signed URL
4. Render a <video> element with the signed URL as src, controls, and playsinline

Layout:
- Left: video player (wide) above lesson description and materials download button
- 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.
- Below video: a Progress component showing course completion percentage (from student_course_progress view)
- 'Mark as Complete' Button: calls supabase.from('lesson_progress').upsert({ student_id, lesson_id, course_id, completed_at: new Date().toISOString() })

Realtime progress update:
- After mounting, subscribe to a Realtime channel on the lesson_progress table filtered by student_id=currentUser.id AND course_id=currentCourseId
- On any INSERT or UPDATE event, refetch the student_course_progress view and update the Progress component
- Unsubscribe on component unmount

Video progress tracking:
- 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.

```
Build an admin course builder at src/pages/admin/CourseBuilder.tsx. Route: /admin/courses/:courseId.

Page layout:
- Course meta editor on the left: title, description, thumbnail upload (public bucket), price, status Select
- Lesson list on the right: ordered by sort_order
  - Each lesson row shows: drag handle, sort_order number, title, duration, preview Toggle (is_preview), Edit button, Delete button
  - Drag-and-drop reordering using HTML5 drag events (no external library needed). On drop, update sort_order for affected lessons in a batch update.

Add Lesson Dialog:
- Title (Input required)
- Description (Textarea)
- Is Preview (Switch)
- 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.
- Materials Upload: optional PDF file input. Upload to 'course-materials' bucket. Store path in materials_storage_path.
- Duration: show auto-detected duration from the video element's metadata after upload.

Publish 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 example

File: `supabase/functions/enroll-student/index.ts`

```typescript
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const cors = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
  'Content-Type': 'application/json',
}

serve(async (req: Request) => {
  if (req.method === 'OPTIONS') return new Response('ok', { headers: cors })

  const authHeader = req.headers.get('Authorization')
  if (!authHeader) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
  }

  const { courseId } = await req.json()

  const userClient = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_ANON_KEY') ?? '',
    { global: { headers: { Authorization: authHeader } } }
  )
  const { data: { user } } = await userClient.auth.getUser()
  if (!user) {
    return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: cors })
  }

  const supabase = createClient(
    Deno.env.get('SUPABASE_URL') ?? '',
    Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
  )

  const { data: course } = await supabase
    .from('courses')
    .select('id, price, status')
    .eq('id', courseId)
    .single()

  if (!course || course.status !== 'published') {
    return new Response(JSON.stringify({ error: 'Course not found' }), { status: 404, headers: cors })
  }

  if (course.price > 0) {
    return new Response(JSON.stringify({ error: 'Payment required', requiresPayment: true, price: course.price }), { status: 402, headers: cors })
  }

  const { error } = await supabase
    .from('enrollments')
    .upsert(
      { student_id: user.id, course_id: courseId, is_active: true },
      { onConflict: 'student_id,course_id' }
    )

  if (error) {
    return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: cors })
  }

  return new Response(JSON.stringify({ success: true }), { headers: cors })
})
```

## Common mistakes

- **Using a public Storage bucket for course videos** — Public bucket files have permanent, shareable URLs. Any student could share the video URL with non-enrolled users, giving away paid content permanently. Fix: 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.
- **Not unsubscribing from Realtime channels on component unmount** — Realtime subscriptions persist after navigation. If a student navigates between lessons rapidly, each lesson creates a new subscription without removing old ones, causing multiple conflicting updates and memory leaks. Fix: 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'.
- **Storing video URLs instead of storage paths in the database** — Generated URLs contain timestamps and signatures that expire. If you store a signed URL in the database, it will stop working. If you store the raw object URL, it only works for public buckets. Fix: 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.
- **Loading all lessons on every page render without RLS optimization** — The lessons RLS policy uses an EXISTS subquery on enrollments for every row. On a course with 50 lessons, this runs 50 existence checks per query load, which is slow. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/online-education-platform
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/online-education-platform
