Build a Headless LMS with Supabase and Next.js in 2026
I've built three LMS platforms in the last two years. Two of them used off-the-shelf solutions that we eventually ripped out. The third -- the one that actually worked -- was a headless architecture built on Next.js and Supabase. That's the one I'm going to walk you through building.
The pitch for a headless LMS is simple: commercial platforms like Teachable, Thinkific, or even Moodle give you a monolithic experience where the content model, the frontend, the auth system, and the payment flow are all welded together. The moment you need something custom -- role-based content gating, AI-powered quiz generation, white-labeled multi-tenant deployments -- you're fighting the platform instead of building features.
With Supabase handling your database, auth, storage, and real-time subscriptions, and Next.js handling your rendering and API layer, you get a system that's genuinely yours. No vendor lock-in on the content side. Full control over the learner experience. And a Postgres database you can query directly without an HTTP round-trip from server components.
Let's build it.
Table of Contents
- Why Go Headless for an LMS
- Architecture Overview
- Database Schema Design
- Setting Up Supabase Auth with Role-Based Access
- Row Level Security for Multi-Tenant Content
- Building the Next.js App Router Frontend
- Course Content Rendering and Progress Tracking
- Real-Time Features: Discussions and Notifications
- Video and File Storage with Supabase Storage
- Deployment and Performance Considerations
- FAQ

Why Go Headless for an LMS
Traditional LMS platforms weren't built for the modern web. They were built for IT departments that wanted to check a compliance box. The user experience reflects that.
A headless LMS separates your content management and business logic from your presentation layer. This means:
- Your frontend can be anything. A Next.js web app, a React Native mobile app, a Slack bot that delivers micro-lessons. The same API serves them all.
- Your content model is yours. You define what a "course" is. Maybe it's a series of markdown lessons. Maybe it's a collection of video modules with embedded interactive exercises. You're not constrained by what Moodle thinks a course should be.
- You control the data. Learner progress, assessment results, engagement metrics -- it all lives in your Postgres database. No exporting CSVs from a vendor dashboard.
The trade-off is obvious: you have to build more yourself. But with Supabase handling auth, storage, real-time, and the database layer, the amount of custom code you actually write is surprisingly small.
Architecture Overview
Here's the high-level architecture for what we're building:
┌─────────────────────────────────────────────┐
│ Next.js 15 │
│ (App Router + RSC) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ │
│ │ Server │ │ Server │ │ Client │ │
│ │Components│ │ Actions │ │Components │ │
│ └────┬─────┘ └────┬─────┘ └─────┬─────┘ │
│ │ │ │ │
│ └──────────────┼──────────────┘ │
│ │ │
│ Supabase SSR Client │
└──────────────────────┬───────────────────────┘
│
┌─────────────┼─────────────┐
│ │ │
┌────▼────┐ ┌─────▼────┐ ┌────▼─────┐
│Supabase │ │Supabase │ │Supabase │
│ Auth │ │ Database │ │ Storage │
│ │ │(Postgres)│ │ (S3) │
└─────────┘ └──────────┘ └──────────┘
│
┌─────▼─────┐
│ Realtime │
│ (WebSocket)│
└───────────┘
The key architectural decisions:
| Decision | Choice | Rationale |
|---|---|---|
| Rendering | Server Components + ISR | Course catalog pages are mostly static; learner dashboards need fresh data |
| Auth | Supabase Auth with PKCE | Built-in RLS integration, no separate auth service |
| Database | Supabase Postgres | Direct queries from server components, no ORM needed |
| File Storage | Supabase Storage | Signed URLs for video content, RLS-gated access |
| Real-time | Supabase Realtime | Discussion forums, live progress updates |
| Payments | Stripe (via webhooks) | Supabase Edge Functions handle webhook processing |
| Content Format | MDX stored in DB | Structured content that renders in React components |
Server components in Next.js 15 can query Supabase directly using the server client -- no API route needed, no serialization boundary. This is a massive win for an LMS where you're fetching course data, user progress, and enrollment status on every page load.
Database Schema Design
The schema is the backbone of any LMS. I've gone through enough iterations to know that getting this right early saves you weeks later. Here's a schema that handles multi-tenant courses, structured modules, progress tracking, and assessments.
-- Organizations (for multi-tenant / white-label)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
settings JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User profiles extending Supabase auth
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'learner' CHECK (role IN ('admin', 'instructor', 'learner')),
organization_id UUID REFERENCES organizations(id),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Courses
CREATE TABLE courses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
instructor_id UUID REFERENCES profiles(id),
title TEXT NOT NULL,
slug TEXT NOT NULL,
description TEXT,
thumbnail_url TEXT,
status TEXT DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
price_cents INTEGER DEFAULT 0,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, slug)
);
-- Modules (sections within a course)
CREATE TABLE modules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Lessons (individual content units)
CREATE TABLE lessons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID REFERENCES modules(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('video', 'text', 'quiz', 'assignment')),
content JSONB NOT NULL DEFAULT '{}',
sort_order INTEGER NOT NULL DEFAULT 0,
duration_minutes INTEGER,
is_free_preview BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Enrollments
CREATE TABLE enrollments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
course_id UUID REFERENCES courses(id) ON DELETE CASCADE,
enrolled_at TIMESTAMPTZ DEFAULT NOW(),
completed_at TIMESTAMPTZ,
stripe_payment_id TEXT,
UNIQUE(user_id, course_id)
);
-- Progress tracking
CREATE TABLE lesson_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
status TEXT DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed')),
progress_pct SMALLINT DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, lesson_id)
);
-- Quiz attempts
CREATE TABLE quiz_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
answers JSONB NOT NULL,
score SMALLINT,
passed BOOLEAN,
attempted_at TIMESTAMPTZ DEFAULT NOW()
);
A few design notes:
- The
contentcolumn on lessons is JSONB. This is intentional. A video lesson stores{"video_url": "...", "transcript": "..."}. A text lesson stores{"mdx": "..."}. A quiz stores{"questions": [...]}. This gives you structured data without needing a separate table for every content type. sort_orderon modules and lessons. Drag-and-drop reordering is a must for any course builder. Integer sort orders make this trivial.- The
metadataJSONB on courses. This is your escape hatch. SEO fields, custom branding, feature flags -- throw it all in here without schema migrations.

Setting Up Supabase Auth with Role-Based Access
Supabase Auth gives you email/password, OAuth, and magic links out of the box. For an LMS, you typically need three roles: admin, instructor, and learner. The trick is syncing Supabase's auth.users with your profiles table.
First, set up a database trigger that creates a profile whenever a new user signs up:
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, email, full_name, avatar_url)
VALUES (
NEW.id,
NEW.email,
NEW.raw_user_meta_data->>'full_name',
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
Now, on the Next.js side, configure the Supabase SSR client. This is the 2026 pattern using @supabase/ssr:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Called from a Server Component -- ignore
}
},
},
}
)
}
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
The server client reads auth tokens from cookies automatically. In a server component, you can do this:
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: profile } = await supabase
.from('profiles')
.select('*, organization:organizations(*)')
.eq('id', user.id)
.single()
const { data: enrollments } = await supabase
.from('enrollments')
.select('*, course:courses(*)')
.eq('user_id', user.id)
return (
<div>
<h1>Welcome back, {profile?.full_name}</h1>
{/* Render enrolled courses */}
</div>
)
}
No API route. No fetch call. Direct database query from a server component. This is the architectural advantage of Supabase + Next.js App Router.
Row Level Security for Multi-Tenant Content
RLS is what makes this architecture actually secure. Without it, your anon key would give anyone access to everything. With RLS, the database itself enforces who can see and modify what.
Here are the policies for the core tables:
-- Courses: anyone can read published courses, instructors can manage their own
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Published courses are visible to all" ON courses
FOR SELECT USING (status = 'published');
CREATE POLICY "Instructors manage their courses" ON courses
FOR ALL USING (
auth.uid() = instructor_id
);
CREATE POLICY "Admins manage org courses" ON courses
FOR ALL USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'admin'
AND organization_id = courses.organization_id
)
);
-- Lessons: enrolled users can read, free previews visible to all
ALTER TABLE lessons ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Free preview lessons visible to all" ON lessons
FOR SELECT USING (is_free_preview = true);
CREATE POLICY "Enrolled users can read lessons" ON lessons
FOR SELECT USING (
EXISTS (
SELECT 1 FROM enrollments e
JOIN modules m ON m.course_id = e.course_id
WHERE e.user_id = auth.uid()
AND m.id = lessons.module_id
)
);
-- Progress: users can only see and update their own progress
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users manage own progress" ON lesson_progress
FOR ALL USING (auth.uid() = user_id);
-- Enrollments: users see their own, instructors see their course enrollments
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see own enrollments" ON enrollments
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Instructors see course enrollments" ON enrollments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM courses
WHERE courses.id = enrollments.course_id
AND courses.instructor_id = auth.uid()
)
);
One thing that trips people up: RLS policies are additive for SELECT. If any policy passes, the row is visible. For INSERT, UPDATE, and DELETE, all applicable policies must pass. Keep this in mind when debugging access issues.
Building the Next.js App Router Frontend
Here's the route structure I've found works well for an LMS:
app/
├── (marketing)/
│ ├── page.tsx # Landing page
│ ├── courses/
│ │ ├── page.tsx # Course catalog
│ │ └── [slug]/
│ │ └── page.tsx # Course detail / sales page
├── (app)/
│ ├── layout.tsx # Authenticated layout with sidebar
│ ├── dashboard/
│ │ └── page.tsx # Learner dashboard
│ ├── learn/
│ │ └── [courseSlug]/
│ │ └── [lessonId]/
│ │ └── page.tsx # Lesson viewer
│ ├── instructor/
│ │ ├── courses/
│ │ │ ├── page.tsx # Instructor course list
│ │ │ ├── new/
│ │ │ │ └── page.tsx # Create course
│ │ │ └── [id]/
│ │ │ └── edit/
│ │ │ └── page.tsx # Course editor
├── api/
│ └── webhooks/
│ └── stripe/
│ └── route.ts # Stripe webhook handler
The route groups (marketing) and (app) let you use different layouts without affecting the URL structure. Marketing pages get a minimal header and footer. The app section gets a full sidebar with course navigation.
Here's a server action for enrolling in a free course:
// app/actions/enroll.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function enrollInCourse(courseId: string) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return { error: 'You must be logged in to enroll' }
}
// Check if course is free
const { data: course } = await supabase
.from('courses')
.select('price_cents')
.eq('id', courseId)
.single()
if (course?.price_cents && course.price_cents > 0) {
return { error: 'This course requires payment' }
}
const { error } = await supabase
.from('enrollments')
.insert({ user_id: user.id, course_id: courseId })
if (error) {
if (error.code === '23505') {
return { error: 'You are already enrolled in this course' }
}
return { error: error.message }
}
revalidatePath('/dashboard')
return { success: true }
}
Server actions in Next.js 15 are the cleanest way to handle mutations. No API routes, no manual fetch calls. The form just calls the function.
Course Content Rendering and Progress Tracking
For the lesson viewer, you need two things happening simultaneously: rendering the content and tracking progress. Here's my approach:
// app/(app)/learn/[courseSlug]/[lessonId]/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import { LessonViewer } from '@/components/lesson-viewer'
import { ProgressTracker } from '@/components/progress-tracker'
export default async function LessonPage({
params,
}: {
params: Promise<{ courseSlug: string; lessonId: string }>
}) {
const { courseSlug, lessonId } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: lesson } = await supabase
.from('lessons')
.select(`
*,
module:modules(
*,
course:courses(*)
)
`)
.eq('id', lessonId)
.single()
if (!lesson) redirect('/dashboard')
// Get all lessons in this course for navigation
const { data: allLessons } = await supabase
.from('lessons')
.select('id, title, sort_order, module_id, content_type')
.eq('module_id', lesson.module_id)
.order('sort_order')
// Get current progress
const { data: progress } = await supabase
.from('lesson_progress')
.select('*')
.eq('user_id', user.id)
.eq('lesson_id', lessonId)
.maybeSingle()
return (
<div className="flex h-screen">
<aside className="w-72 border-r overflow-y-auto">
{/* Course sidebar with lesson list */}
</aside>
<main className="flex-1 overflow-y-auto">
<LessonViewer
lesson={lesson}
progress={progress}
/>
<ProgressTracker
lessonId={lessonId}
initialProgress={progress?.progress_pct ?? 0}
/>
</main>
</div>
)
}
The ProgressTracker is a client component that updates progress as the user scrolls through text content or watches video. It uses the browser Supabase client to write progress in real-time:
// components/progress-tracker.tsx
'use client'
import { useEffect, useCallback } from 'react'
import { createClient } from '@/lib/supabase/client'
import { useDebounce } from '@/hooks/use-debounce'
export function ProgressTracker({
lessonId,
initialProgress,
}: {
lessonId: string
initialProgress: number
}) {
const supabase = createClient()
const updateProgress = useCallback(
async (pct: number) => {
await supabase
.from('lesson_progress')
.upsert(
{
user_id: (await supabase.auth.getUser()).data.user?.id,
lesson_id: lessonId,
progress_pct: pct,
status: pct >= 100 ? 'completed' : 'in_progress',
completed_at: pct >= 100 ? new Date().toISOString() : null,
},
{ onConflict: 'user_id,lesson_id' }
)
},
[lessonId, supabase]
)
const debouncedUpdate = useDebounce(updateProgress, 2000)
useEffect(() => {
const handleScroll = () => {
const scrollPct = Math.round(
(window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100
)
if (scrollPct > initialProgress) {
debouncedUpdate(scrollPct)
}
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [debouncedUpdate, initialProgress])
return null // This component has no UI
}
Debouncing the progress updates is critical. You don't want to hit the database on every scroll event.
Real-Time Features: Discussions and Notifications
Supabase Realtime turns your LMS from a static content viewer into something that feels alive. The two features I always build first: lesson discussions and progress notifications for instructors.
// components/lesson-discussion.tsx
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function LessonDiscussion({ lessonId }: { lessonId: string }) {
const supabase = createClient()
const [messages, setMessages] = useState<any[]>([])
useEffect(() => {
// Fetch existing messages
supabase
.from('discussions')
.select('*, profile:profiles(full_name, avatar_url)')
.eq('lesson_id', lessonId)
.order('created_at')
.then(({ data }) => setMessages(data ?? []))
// Subscribe to new messages
const channel = supabase
.channel(`lesson-${lessonId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'discussions',
filter: `lesson_id=eq.${lessonId}`,
},
async (payload) => {
// Fetch the full message with profile
const { data } = await supabase
.from('discussions')
.select('*, profile:profiles(full_name, avatar_url)')
.eq('id', payload.new.id)
.single()
if (data) setMessages((prev) => [...prev, data])
}
)
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [lessonId, supabase])
return (
<div>
{/* Render messages and input form */}
</div>
)
}
Video and File Storage with Supabase Storage
For video content, Supabase Storage with signed URLs is the way to go. Create a private bucket for course videos and use RLS-like policies on the storage layer:
-- Storage bucket policy: only enrolled users can access course videos
CREATE POLICY "Enrolled users can view course videos"
ON storage.objects FOR SELECT
USING (
bucket_id = 'course-videos'
AND EXISTS (
SELECT 1 FROM enrollments e
JOIN courses c ON c.id = e.course_id
WHERE e.user_id = auth.uid()
AND storage.filename(name) LIKE c.id || '/%'
)
);
On the server side, generate short-lived signed URLs:
const { data } = await supabase.storage
.from('course-videos')
.createSignedUrl(`${courseId}/${lesson.video_filename}`, 3600) // 1 hour
For production, you'll want to put a CDN in front of this. Supabase's built-in CDN handles most cases, but for high-traffic video delivery, consider transcoding with a service like Mux or Cloudflare Stream and storing just the playback IDs in your database.
Deployment and Performance Considerations
The Supabase free tier gives you 500MB database, 1GB storage, and 50,000 monthly active users. That's enough to launch. The Pro plan at $25/month gives you 8GB database, 100GB storage, and no MAU limit -- which handles a serious LMS with thousands of learners.
| Supabase Plan | DB Storage | File Storage | MAU Limit | Price |
|---|---|---|---|---|
| Free | 500MB | 1GB | 50,000 | $0 |
| Pro | 8GB | 100GB | Unlimited | $25/mo |
| Team | 8GB | 100GB | Unlimited | $599/mo |
| Enterprise | Custom | Custom | Unlimited | Custom |
For Next.js deployment, Vercel is the obvious choice -- you get automatic ISR, edge middleware, and image optimization. But if you're cost-sensitive, you can deploy on Coolify or Railway for significantly less.
Performance tips from production:
- Use ISR for course catalog pages.
revalidate: 3600means your catalog rebuilds at most once an hour. Fast enough for most use cases. - Use server components for the lesson viewer. The initial page load is SSR'd with all content. No loading spinners.
- Add database indexes. At minimum:
enrollments(user_id),lesson_progress(user_id, lesson_id),lessons(module_id, sort_order),courses(organization_id, slug). - Connection pooling. Use Supabase's built-in PgBouncer (the pooling connection string) for server-side queries. The direct connection for migrations.
If you're building something like this and want help with the architecture or implementation, our team does Next.js development and headless CMS builds regularly. We've shipped several Supabase-backed applications and know where the sharp edges are.
FAQ
Can Supabase handle the scale of a production LMS?
Yes. Supabase runs on managed Postgres, which handles millions of rows without breaking a sweat. The Pro plan includes connection pooling via PgBouncer, and you can scale compute independently. For reference, Supabase reports customers running 100k+ concurrent connections on their Enterprise tier. For most LMS use cases -- even with tens of thousands of active learners -- the Pro plan at $25/month is more than enough.
How do you handle video hosting in a Supabase-based LMS?
Supabase Storage works for storing and serving video files with signed URLs, but it's not a video platform. For production LMS video, I recommend using a dedicated video service like Mux ($0.007/min for encoding, $0.00015/sec for delivery) or Cloudflare Stream ($1/1000 min stored, $5/1000 min delivered). Store the playback IDs in your Supabase database and use their player SDKs on the frontend. This gives you adaptive bitrate, analytics, and DRM without building any of it yourself.
Is Next.js App Router stable enough for a production LMS in 2026?
Absolutely. The App Router has been stable since Next.js 14, and Next.js 15 ironed out the remaining rough edges with server actions and caching. Server components are a genuine architectural advantage for data-heavy applications like an LMS. The after() API in Next.js 15 is particularly useful for firing analytics events and progress updates without blocking the response.
How do you implement course completion certificates?
Track completion at the lesson level with lesson_progress, then compute course completion as a percentage of lessons completed in all modules. When a user hits 100%, trigger a Supabase Edge Function (or a Next.js server action) that generates a PDF certificate using a library like @react-pdf/renderer and stores it in Supabase Storage. Add a row to a certificates table with the storage path and a unique verification code.
What about SCORM compliance?
SCORM is a legacy standard that most modern LMS platforms are moving away from. That said, if you need SCORM support -- typically for corporate compliance training -- you can parse SCORM packages and store the extracted content in your Supabase database. Libraries like pipwerks-scorm-api-wrapper handle the runtime API. It's doable but adds significant complexity. I'd avoid it unless a client specifically requires it.
How do you handle payments for paid courses?
Stripe Checkout is the simplest path. Create a Stripe Checkout session from a server action, redirect the user to Stripe, and handle the checkout.session.completed webhook in a Next.js API route or Supabase Edge Function. The webhook creates the enrollment record. This keeps your payment logic simple and PCI-compliant without handling credit card data yourself.
Can this architecture support a mobile app too?
That's one of the biggest advantages of going headless. Your Supabase database and auth work identically from a React Native app via @supabase/supabase-js. The same RLS policies protect the data. You just build a different UI layer. We've seen teams ship web and mobile from the same Supabase backend with no API changes.
How do you add AI features like quiz generation or content summarization?
Store your lesson content as structured JSONB (not HTML blobs). Then create a Supabase Edge Function that sends lesson content to an LLM (OpenAI, Anthropic, or a self-hosted model) to generate quiz questions, summaries, or study guides. Store the generated content back in JSONB fields. The structured data model we designed earlier makes this straightforward -- AI systems can consume specific fields without parsing markup. If you're interested in this kind of work, reach out to our team -- we've been building AI integrations into content platforms all year.