過去2年間に3つのLMSプラットフォームを構築した話

過去2年間に3つのLMSプラットフォームを構築しました。そのうち2つは既製ソリューションを使用しており、最終的にそれらを取り除きました。3番目のプラットフォーム(実際に機能したもの)は、Next.jsとSupabaseの上に構築されたヘッドレスアーキテクチャです。今から、このプラットフォームの構築方法を説明します。

ヘッドレスLMSの提案は単純です:Teachable、Thinkific、さらにはMoodleなどの商用プラットフォームは、コンテンツモデル、フロントエンド、認証システム、支払いフローがすべて固く接合された一体型の体験を提供します。ロールベースのコンテンツゲーティング、AI駆動のクイズ生成、ホワイトレーベルのマルチテナントデプロイメントなどのカスタム要件が必要な瞬間、プラットフォームと戦うことになります。機能を構築するのではなく。

Supabaseがデータベース、認証、ストレージ、リアルタイムサブスクリプションを処理し、Next.jsがレンダリングとAPIレイヤーを処理すれば、本当にあなたのシステムが手に入ります。コンテンツ側のベンダーロックインなし。学習者の体験を完全にコントロール。サーバーコンポーネントからのHTTPラウンドトリップなしで直接クエリできるPostgresデータベース。

構築しましょう。

目次

Build a Headless LMS with Supabase and Next.js in 2026

LMSがヘッドレスを採用すべき理由

従来のLMSプラットフォームはモダンウェブのために構築されていません。これらはコンプライアンスチェックボックスをつけたいIT部門のために構築されていました。ユーザー体験はそれを反映しています。

ヘッドレスLMSは、コンテンツ管理とビジネスロジックをプレゼンテーション層から分離します。これは以下を意味します:

  • フロントエンドは何でもあり得ます。 Next.jsウェブアプリ、React Nativeモバイルアプリ、マイクロレッスンを配信するSlackボット。同じAPIがそれらすべてにサービスを提供します。
  • コンテンツモデルはあなたのもの。 「コース」が何かを定義します。もしかしたらマークダウンレッスンの連続かもしれません。もしくは、埋め込まれたインタラクティブな演習を含むビデオモジュールの集合かもしれません。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処理を実行
コンテンツ形式 MDX stored in DB Reactコンポーネントでレンダリングされる構造化コンテンツ

Next.js 15のサーバーコンポーネントは、サーバークライアントを使用してSupabaseを直接クエリできます -- APIルートは不要、シリアル化境界はありません。これはすべてのページロードでコースデータ、ユーザープログレス、登録ステータスをフェッチするLMSにおいて大きな勝利です。

データベーススキーマ設計

スキーマはすべてのLMSの主要基盤です。このアーキテクチャが十分に反復されて、初期段階でこれを正しく行うことが後々週単位の時間を節約することを知っています。これは、マルチテナントコース、構造化モジュール、進捗追跡、評価を処理するスキーマです。

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

スキーム設計に関するいくつかのメモ:

  • レッスンのcontent列はJSONB。 これは意図的です。ビデオレッスンは{"video_url": "...", "transcript": "..."}を保存します。テキストレッスンは{"mdx": "..."}を保存します。クイズは{"questions": [...]}を保存します。これはコンテンツタイプごとに個別のテーブルを必要とせずに構造化データを提供します。
  • モジュールとレッスンのsort_order これは意図的です。ドラッグアンドドロップの並べ替えはあらゆるコースビルダーの必須です。整数ソートオーダーはこれを簡単にします。
  • コースのmetadata JSONB。 これはあなたのエスケープハッチです。SEOフィールド、カスタムブランディング、機能フラグ - スキーママイグレーションなしにすべてここにスローします。

Build a Headless LMS with Supabase and Next.js in 2026 - architecture

ロールベースアクセス制御によるSupabase認証の設定

Supabase Authはメール/パスワード、OAuth、マジックリンクをそのままで提供します。LMSでは、通常3つのロールが必要です:admin、instructor、learner。コツはSupabaseのauth.usersprofilesテーブルと同期することです。

まず、新しいユーザーがサインアップするたびにプロフィールを作成するデータベーストリガーを設定します:

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クライアントを設定します。これは@supabase/ssrを使用した2026年のパターンです:

// 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!
  )
}

サーバークライアントは自動的にクッキーから認証トークンを読み取ります。サーバーコンポーネントでは、これを行うことができます:

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

APIルートなし。fetchコールなし。サーバーコンポーネントからの直接データベースクエリ。これはSupabase + Next.js App Routerのアーキテクチャ上の利点です。

マルチテナントコンテンツの行レベルセキュリティ

RLSは、このアーキテクチャを実際にセキュアにするものです。なしでは、anonキーは誰もがすべてにアクセスできます。RLSでは、データベース自体が誰が何を見て変更できるかを実行します。

コアテーブルの設定は以下の通りです:

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

人々を戸惑わせる1つのこと:RLSポリシーはSELECTに対して加算的です。いずれかのポリシーが通過すれば、行は見えます。INSERTUPDATEDELETEの場合、すべての適用可能なポリシーは通過する必要があります。アクセス問題をデバッグするときにこれを念頭に置いてください。

Next.js App Routerフロントエンドの構築

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

ルートグループ(marketing)(app)では、URLの構造に影響を与えないで異なるレイアウトを使用できます。マーケティングページは最小限のヘッダーとフッターを取得します。appセクションは完全なサイドバーとコースナビゲーションを取得します。

無料コースに登録するためのサーバーアクションは以下の通りです:

// 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 }
}

Next.js 15のサーバーアクションは、ミューテーションを処理する最もクリーンな方法です。APIルートなし、手動のfetchコールなし。フォームは単にその関数を呼び出します。

コースコンテンツのレンダリングと進捗追跡

レッスンビューアでは、2つのことが同時に起こる必要があります:コンテンツのレンダリングと進捗の追跡。これは私のアプローチです:

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

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 // This component has no UI
}

進捗更新をデバウンスするのは重要です。すべてのスクロールイベントでデータベースにヒットしたくありません。

リアルタイム機能:ディスカッションと通知

Supabase Realtimeはあなたのポイント・イン・タイム・ストレージをまるで生きているように感じさせるものに変えます。常に最初に構築する2つの機能:レッスンディスカッションと講師向けの進捗通知。

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

Supabase Storageを使ったビデオとファイルストレージ

ビデオコンテンツについては、署名付きURLを備えたSupabase Storageが方法です。コースビデオ用に秘密のバケットを作成し、ストレージレイヤー上のRLSのようなポリシーを使用します:

-- 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 || '/%'
  )
);

サーバー側では、短命の署名付きURLを生成します:

const { data } = await supabase.storage
  .from('course-videos')
  .createSignedUrl(`${courseId}/${lesson.video_filename}`, 3600) // 1 hour

本番環境では、これの前にCDNを配置する必要があります。Supabaseの組み込みCDNはほとんどのケースを処理しますが、高トラフィックビデオ配信の場合、MuxやCloudflare Streamなどのサービスで トランスコードし、データベースに再生IDだけを保存することを検討します。

デプロイメントとパフォーマンスの考慮事項

Supabaseの無料層は500MBデータベース、1GBストレージ、毎月50,000アクティブユーザーを提供します。これは起動するのに十分です。プロプランは月$25で8GBデータベース、100GBストレージ、MAU制限なし - これは数千の学習者を持つ真のLMSを処理します。

Supabaseプラン DBストレージ ファイルストレージ MAU制限 価格
無料 500MB 1GB 50,000 $0
プロ 8GB 100GB 無制限 $25/月
チーム 8GB 100GB 無制限 $599/月
エンタープライズ カスタム カスタム 無制限 カスタム

Next.jsのデプロイメント向けに、Vercelは明らかな選択です -- 自動ISR、エッジミドルウェア、画像最適化を取得します。ただし、コストに敏感な場合、Coolifyまたはなるほどにデプロイすることができます。

本番環境からのパフォーマンスティップ:

  • コースカタログページをISRに使用。 revalidate: 3600はカタログが最大1時間に1回リビルドされることを意味します。ほとんどのユースケースで十分に速い。
  • レッスンビューアにサーバーコンポーネントを使用。 初期ページロードはすべてのコンテンツでSSR'd。ローディングスピナーなし。
  • データベースインデックスを追加。 最低でも: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で実行され、息をのむほどの行を処理します。プロプランには、PgBouncerによる接続プーリングが含まれ、コンピュートを独立してスケールできます。参考までに、Supabaseはエンタープライズティアで100,000以上の並行接続を実行する顧客をレポートしています。ほとんどのLMSユースケース - 数万のアクティブ学習者がいるとしても - 月$25のプロプランで十分以上です。

SupabaseベースのヘッドレスなLMSでビデオホスティングをどのように処理しますか? Supabase Storageは署名付きURLを使用してビデオファイルを保存および配信するために機能しますが、ビデオプラットフォームではありません。本番環境LMSビデオのために、Mux(エンコーディング用$0.007/分、配信用$0.00015/秒)やCloudflare Stream(保存用$1/1000分、配信用$5/1000分)のような専用ビデオサービスを使用することをお勧めします。再生IDをSupabaseデータベースに保存し、フロントエンドでそのプレイヤーSDKを使用します。これはあなた自身でそれを構築することなく、適応ビットレート、分析、DRMを提供します。

Next.js App Routerは2026年の本番LMSのために十分に安定していますか? 絶対に。App Routerはは次.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.completedwebhookを処理します。webhookは登録レコードを作成します。これは支払いロジックを単純に保ち、クレジットカードデータ自体を処理することなくPCI準拠です。

このアーキテクチャはモバイルアプリもサポートできますか? これはヘッドレスを採用することの最大の利点の1つです。Supabaseデータベースと認証は@supabase/supabase-js経由のReact Nativeアプリから同一に機能します。同じRLSポリシーはデータを保護します。異なるUIレイヤーを構築するだけです。同じSupabaseバックエンドから APIの変更なしにウェブとモバイルを提供するチームを見てきました。

AI機能(クイズ生成やコンテンツ要約)などをどのように追加しますか? レッスンコンテンツを構造化JSONB(HTMLブロブではなく)として保存します。次に、レッスンコンテンツをLLM(OpenAI、Anthropic、または自己ホストモデル)に送信して、クイズの質問、要約、または学習ガイドを生成するSupabase Edge Functionを作成します。生成されたコンテンツをJSONBフィールドに保存します。設計された構造化データモデルは、AIシステムがマークアップを解析することなく特定のフィールドを利用することができるため、これを簡単にしています。この種の作業に興味がある場合は、チームに連絡してください - 私たちは一年中コンテンツプラットフォームにAI統合を構築しています。