在 2026 年使用 Supabase 和 Next.js 构建无头 LMS

在过去两年中,我构建了三个 LMS 平台。其中两个使用了现成的解决方案,最终我们将其移除了。第三个——真正有效的那个——是建立在 Next.js 和 Supabase 上的无头架构。这就是我要向你介绍如何构建的内容。

无头 LMS 的理由很简单:Teachable、Thinkific 甚至 Moodle 等商业平台为你提供一个庞然大物的体验,其中内容模型、前端、身份验证系统和支付流程都被焊接在一起。当你需要任何自定义功能时——基于角色的内容网关、AI 驱动的测验生成、白标多租户部署——你就是在与平台对抗,而不是在构建功能。

由 Supabase 处理数据库、身份验证、存储和实时订阅,以及 Next.js 处理渲染和 API 层,你会获得一个真正属于你自己的系统。内容方面没有供应商锁定。对学习者体验拥有完全控制权。以及一个可以直接查询的 Postgres 数据库,无需从服务器组件进行 HTTP 往返。

让我们构建它。

目录

在 2026 年使用 Supabase 和 Next.js 构建无头 LMS

为什么为 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 拖放重新排序是任何课程构建器的必须功能。整数排序顺序使这变得微不足道。
  • 课程中的 metadata JSONB。 这是你的逃生舱。SEO 字段、自定义品牌、功能标志——把它全部扔在这里,而无需进行架构迁移。

在 2026 年使用 Supabase 和 Next.js 构建无头 LMS - 架构

使用基于角色的访问设置 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 是加法的。如果任何策略通过,该行是可见的。对于 INSERTUPDATEDELETE,所有适用的策略必须通过。在调试访问问题时请记住这一点。

构建 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 集成。