使用 Supabase 和 Next.js 在 2026 年构建无头 LMS
在 2026 年使用 Supabase 和 Next.js 构建无头 LMS
在过去两年中,我构建了三个 LMS 平台。其中两个使用了现成的解决方案,最终我们将其移除了。第三个——真正有效的那个——是建立在 Next.js 和 Supabase 上的无头架构。这就是我要向你介绍如何构建的内容。
无头 LMS 的理由很简单:Teachable、Thinkific 甚至 Moodle 等商业平台为你提供一个庞然大物的体验,其中内容模型、前端、身份验证系统和支付流程都被焊接在一起。当你需要任何自定义功能时——基于角色的内容网关、AI 驱动的测验生成、白标多租户部署——你就是在与平台对抗,而不是在构建功能。
由 Supabase 处理数据库、身份验证、存储和实时订阅,以及 Next.js 处理渲染和 API 层,你会获得一个真正属于你自己的系统。内容方面没有供应商锁定。对学习者体验拥有完全控制权。以及一个可以直接查询的 Postgres 数据库,无需从服务器组件进行 HTTP 往返。
让我们构建它。
目录
- 为什么为 LMS 选择无头架构
- 架构概览
- 数据库模式设计
- 使用基于角色的访问设置 Supabase 身份验证
- 多租户内容的行级安全
- 构建 Next.js App Router 前端
- 课程内容渲染和进度跟踪
- 实时功能:讨论和通知
- 使用 Supabase Storage 的视频和文件存储
- 部署和性能考虑
- 常见问题

为什么为 LMS 选择无头架构
传统 LMS 平台不是为现代网络构建的。它们是为希望勾选合规性复选框的 IT 部门而构建的。用户体验反映了这一点。
无头 LMS 将你的内容管理和业务逻辑与呈现层分离。这意味着:
- 你的前端可以是任何东西。 一个 Next.js web 应用、一个 React Native 移动应用、一个传递微课程的 Slack 机器人。相同的 API 为他们全部服务。
- 你的内容模型属于你。 你定义"课程"是什么。也许它是一系列 markdown 课程。也许它是具有嵌入式交互式练习的视频模块的集合。你不受 Moodle 认为课程应该是什么样的限制。
- 你控制数据。 学习者进度、评估结果、参与度指标——它们都存在于你的 Postgres 数据库中。无需从供应商仪表板导出 CSV。
权衡是显而易见的:你必须自己构建更多内容。但由于 Supabase 处理身份验证、存储、实时和数据库层,你实际编写的自定义代码量出奇地少。
架构概览
这是我们正在构建的高级架构:
┌─────────────────────────────────────────────┐
│ 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)│
└───────────┘
关键的架构决策:
| 决策 | 选择 | 理由 |
|---|---|---|
| 渲染 | 服务器组件 + ISR | 课程目录页面大多是静态的;学习者仪表板需要新鲜数据 |
| 身份验证 | Supabase Auth with PKCE | 内置 RLS 集成,无需单独的身份验证服务 |
| 数据库 | Supabase Postgres | 从服务器组件直接查询,无需 ORM |
| 文件存储 | Supabase Storage | 视频内容的签名 URL,RLS 门控访问 |
| 实时 | Supabase Realtime | 讨论论坛、实时进度更新 |
| 支付 | Stripe(通过 webhooks) | Supabase Edge Functions 处理 webhook 处理 |
| 内容格式 | 存储在 DB 中的 MDX | 在 React 组件中呈现的结构化内容 |
Next.js 15 中的服务器组件可以直接使用服务器客户端查询 Supabase——无需 API 路由、无需序列化边界。这对 LMS 是一个巨大的胜利,因为你在每次页面加载时都在获取课程数据、用户进度和注册状态。
数据库模式设计
模式是任何 LMS 的支柱。我经历了足够的迭代,知道尽早做对这一点可以节省你数周时间。这是一个处理多租户课程、结构化模块、进度跟踪和评估的模式。
-- 组织(用于多租户 / 白标)
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()
);
-- 扩展 Supabase 身份验证的用户档案
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()
);
-- 课程
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)
);
-- 模块(课程内的部分)
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()
);
-- 课程(单个内容单元)
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()
);
-- 注册
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)
);
-- 进度跟踪
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)
);
-- 测验尝试
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()
);
一些设计说明:
- 课程中的
content列是 JSONB。 这是有意的。视频课程存储{"video_url": "...", "transcript": "..."}。文本课程存储{"mdx": "..."}。测验存储{"questions": [...]}。这为你提供结构化数据,而无需为每种内容类型创建单独的表。 - 模块和课程中的
sort_order。 拖放重新排序是任何课程构建器的必须功能。整数排序顺序使这变得微不足道。 - 课程中的
metadataJSONB。 这是你的逃生舱。SEO 字段、自定义品牌、功能标志——把它全部扔在这里,而无需进行架构迁移。

使用基于角色的访问设置 Supabase 身份验证
Supabase Auth 开箱即用地提供电子邮件/密码、OAuth 和魔法链接。对于 LMS,你通常需要三个角色:管理员、讲师和学习者。诀窍是将 Supabase 的 auth.users 与你的 profiles 表同步。
首先,设置一个数据库触发器,每当新用户注册时创建一个档案:
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();
现在,在 Next.js 端,配置 Supabase SSR 客户端。这是 2026 年使用 @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 {
// 从服务器组件调用 -- 忽略
}
},
},
}
)
}
// 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!
)
}
服务器客户端自动从 cookie 中读取身份验证令牌。在服务器组件中,你可以这样做:
// 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>欢迎回来,{profile?.full_name}</h1>
{/* 渲染注册课程 */}
</div>
)
}
无 API 路由。无 fetch 调用。直接从服务器组件进行数据库查询。这是 Supabase + Next.js App Router 的架构优势。
多租户内容的行级安全
RLS 是使这个架构真正安全的原因。没有它,你的 anon 密钥会给任何人访问权限。有了 RLS,数据库本身会强制谁可以看到和修改什么。
这是核心表的策略:
-- 课程:任何人都可以读取已发布的课程,讲师可以管理自己的课程
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "已发布课程对所有人可见" ON courses
FOR SELECT USING (status = 'published');
CREATE POLICY "讲师管理他们的课程" ON courses
FOR ALL USING (
auth.uid() = instructor_id
);
CREATE POLICY "管理员管理组织课程" ON courses
FOR ALL USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'admin'
AND organization_id = courses.organization_id
)
);
-- 课程:注册用户可以读取,免费预览对所有人可见
ALTER TABLE lessons ENABLE ROW LEVEL SECURITY;
CREATE POLICY "免费预览课程对所有人可见" ON lessons
FOR SELECT USING (is_free_preview = true);
CREATE POLICY "注册用户可以读取课程" 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
)
);
-- 进度:用户只能看到和更新自己的进度
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
CREATE POLICY "用户管理自己的进度" ON lesson_progress
FOR ALL USING (auth.uid() = user_id);
-- 注册:用户看到他们自己的,讲师看到他们课程的注册
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "用户看到自己的注册" ON enrollments
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "讲师看到课程注册" ON enrollments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM courses
WHERE courses.id = enrollments.course_id
AND courses.instructor_id = auth.uid()
)
);
困扰人们的一件事:RLS 策略对 SELECT 是加法的。如果任何策略通过,该行是可见的。对于 INSERT、UPDATE 和 DELETE,所有适用的策略必须通过。在调试访问问题时请记住这一点。
构建 Next.js App Router 前端
这是我发现适用于 LMS 的路由结构:
app/
├── (marketing)/
│ ├── page.tsx # 登录页面
│ ├── courses/
│ │ ├── page.tsx # 课程目录
│ │ └── [slug]/
│ │ └── page.tsx # 课程详情 / 销售页面
├── (app)/
│ ├── layout.tsx # 带侧边栏的身份验证布局
│ ├── dashboard/
│ │ └── page.tsx # 学习者仪表板
│ ├── learn/
│ │ └── [courseSlug]/
│ │ └── [lessonId]/
│ │ └── page.tsx # 课程查看器
│ ├── instructor/
│ │ ├── courses/
│ │ │ ├── page.tsx # 讲师课程列表
│ │ │ ├── new/
│ │ │ │ └── page.tsx # 创建课程
│ │ │ └── [id]/
│ │ │ └── edit/
│ │ │ └── page.tsx # 课程编辑器
├── api/
│ └── webhooks/
│ └── stripe/
│ └── route.ts # Stripe webhook 处理程序
路由组 (marketing) 和 (app) 让你使用不同的布局,而无需影响 URL 结构。营销页面获得最小标题和页脚。应用部分获得具有课程导航的完整侧边栏。
这是用于注册免费课程的服务器操作:
// 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: '你必须登录才能注册' }
}
// 检查课程是否免费
const { data: course } = await supabase
.from('courses')
.select('price_cents')
.eq('id', courseId)
.single()
if (course?.price_cents && course.price_cents > 0) {
return { error: '此课程需要付款' }
}
const { error } = await supabase
.from('enrollments')
.insert({ user_id: user.id, course_id: courseId })
if (error) {
if (error.code === '23505') {
return { error: '你已经注册了此课程' }
}
return { error: error.message }
}
revalidatePath('/dashboard')
return { success: true }
}
Next.js 15 中的服务器操作是处理变更的最简洁方式。没有 API 路由,没有手动 fetch 调用。表单只是调用函数。
课程内容渲染和进度跟踪
对于课程查看器,你需要两件事同时发生:渲染内容和跟踪进度。这是我的方法:
// 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')
// 获取此课程中的所有课程用于导航
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')
// 获取当前进度
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">
{/* 具有课程列表的课程侧边栏 */}
</aside>
<main className="flex-1 overflow-y-auto">
<LessonViewer
lesson={lesson}
progress={progress}
/>
<ProgressTracker
lessonId={lessonId}
initialProgress={progress?.progress_pct ?? 0}
/>
</main>
</div>
)
}
ProgressTracker 是一个客户端组件,当用户滚动浏览文本内容或观看视频时更新进度。它使用浏览器 Supabase 客户端实时写入进度:
// 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 // 此组件没有 UI
}
对进度更新进行去抖动至关重要。你不想在每个滚动事件上都点击数据库。
实时功能:讨论和通知
Supabase Realtime 将你的 LMS 从静态内容查看器转变为感觉生动的东西。我总是首先构建的两个功能:课程讨论和讲师的进度通知。
// 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(() => {
// 获取现有消息
supabase
.from('discussions')
.select('*, profile:profiles(full_name, avatar_url)')
.eq('lesson_id', lessonId)
.order('created_at')
.then(({ data }) => setMessages(data ?? []))
// 订阅新消息
const channel = supabase
.channel(`lesson-${lessonId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'discussions',
filter: `lesson_id=eq.${lessonId}`,
},
async (payload) => {
// 获取带有档案的完整消息
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>
{/* 呈现消息和输入表单 */}
</div>
)
}
使用 Supabase Storage 的视频和文件存储
对于视频内容,带有签名 URL 的 Supabase Storage 是正确的方法。为课程视频创建一个私有存储桶,并在存储层上使用 RLS 类似的策略:
-- 存储桶策略:仅注册用户可以访问课程视频
CREATE POLICY "注册用户可以查看课程视频"
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 || '/%'
)
);
在服务器端,生成短期签名 URL:
const { data } = await supabase.storage
.from('course-videos')
.createSignedUrl(`${courseId}/${lesson.video_filename}`, 3600) // 1 小时
对于生产,你会希望在其前面放置一个 CDN。Supabase 的内置 CDN 可以处理大多数情况,但对于高流量视频传递,请考虑使用 Mux 或 Cloudflare Stream 等服务进行转码,并仅在数据库中存储播放 ID。
部署和性能考虑
Supabase 免费层为你提供 500MB 数据库、1GB 存储和 50,000 月活跃用户。这足以启动。Pro 计划每月 $25,为你提供 8GB 数据库、100GB 存储和无 MAU 限制——这可以处理具有数千名学习者的真正 LMS。
| Supabase 计划 | DB 存储 | 文件存储 | MAU 限制 | 价格 |
|---|---|---|---|---|
| 免费 | 500MB | 1GB | 50,000 | $0 |
| Pro | 8GB | 100GB | 无限制 | $25/月 |
| 团队 | 8GB | 100GB | 无限制 | $599/月 |
| 企业 | 自定义 | 自定义 | 无限制 | 自定义 |
对于 Next.js 部署,Vercel 是显而易见的选择——你获得自动 ISR、边缘中间件和图像优化。但如果你对成本敏感,你可以在 Coolify 或 Railway 上部署,成本要低得多。
生产中的性能提示:
- 为课程目录页面使用 ISR。
revalidate: 3600意味着你的目录最多每小时重建一次。对于大多数用例来说足够快。 - 为课程查看器使用服务器组件。 初始页面加载使用所有内容进行 SSR。没有加载微调器。
- 添加数据库索引。 最少:
enrollments(user_id)、lesson_progress(user_id, lesson_id)、lessons(module_id, sort_order)、courses(organization_id, slug)。 - 连接池。 使用 Supabase 的内置 PgBouncer(池连接字符串)进行服务器端查询。直接连接用于迁移。
如果你正在构建类似的东西并希望获得架构或实现方面的帮助,我们的团队定期进行 Next.js 开发 和 无头 CMS 构建。我们已经发货了几个 Supabase 支持的应用程序,知道锋利的边缘在哪里。
常见问题
Supabase 能否处理生产 LMS 的规模? 是的。Supabase 在托管 Postgres 上运行,可以毫不费力地处理数百万行。Pro 计划包括通过 PgBouncer 的连接池,你可以独立扩展计算。作为参考,Supabase 报告客户在其企业层上运行 100k+ 并发连接。对于大多数 LMS 用例——即使有数万名活跃学习者——Pro 计划每月 $25 也绰绰有余。
你如何在基于 Supabase 的 LMS 中处理视频托管? Supabase Storage 适合存储和提供带有签名 URL 的视频文件,但它不是视频平台。对于生产 LMS 视频,我建议使用专用视频服务,如 Mux(编码每分钟 $0.007,交付每秒 $0.00015)或 Cloudflare Stream(存储 1000 分钟 $1,交付 1000 分钟 $5)。在你的 Supabase 数据库中存储播放 ID 并在前端使用他们的播放器 SDK。这为你提供自适应比特率、分析和 DRM,而无需自己构建任何东西。
2026 年 Next.js App Router 对生产 LMS 来说足够稳定吗?
绝对地。App Router 自 Next.js 14 以来已经稳定,Next.js 15 用服务器操作和缓存解决了剩余的粗糙边缘。服务器组件对于数据密集型应用(如 LMS)是真正的架构优势。Next.js 15 中的 after() API 对于触发分析事件和进度更新而不阻止响应特别有用。
你如何实现课程完成证书?
在课程级别使用 lesson_progress 跟踪完成情况,然后将课程完成计算为所有模块中完成课程的百分比。当用户达到 100% 时,触发 Supabase Edge Function(或 Next.js 服务器操作)使用诸如 @react-pdf/renderer 之类的库生成 PDF 证书,并将其存储在 Supabase Storage 中。向 certificates 表添加一行,包含存储路径和唯一的验证代码。
SCORM 合规性呢?
SCORM 是一项遗留标准,大多数现代 LMS 平台都在远离它。话虽如此,如果你需要 SCORM 支持——通常用于企业合规性培训——你可以解析 SCORM 包并将提取的内容存储在 Supabase 数据库中。诸如 pipwerks-scorm-api-wrapper 之类的库可以处理运行时 API。这是可行的,但会增加显著的复杂性。除非客户特别要求,否则我会避免它。
你如何处理付费课程的支付?
Stripe Checkout 是最简单的路径。从服务器操作创建 Stripe Checkout 会话,将用户重定向到 Stripe,并在 Next.js API 路由或 Supabase Edge Function 中处理 checkout.session.completed webhook。webhook 创建注册记录。这使你的支付逻辑简单且符合 PCI 标准,无需自己处理信用卡数据。
此架构是否也可以支持移动应用?
这是选择无头架构的最大优势之一。你的 Supabase 数据库和身份验证通过 @supabase/supabase-js 从 React Native 应用以相同的方式工作。相同的 RLS 策略保护数据。你只需构建不同的 UI 层。我们看到团队从相同的 Supabase 后端发货了网络和移动,无需任何 API 更改。
你如何添加 AI 功能,如测验生成或内容总结? 将课程内容存储为结构化 JSONB(而不是 HTML blob)。然后创建一个 Supabase Edge Function,将课程内容发送到 LLM(OpenAI、Anthropic 或自托管模型)以生成测验问题、摘要或学习指南。将生成的内容存储回 JSONB 字段。我们之前设计的结构化数据模型使这变得简单——AI 系统可以使用特定字段而无需解析标记。如果你对这类工作感兴趣,请与我们的团队联系——我们全年一直在为内容平台构建 AI 集成。