Ich habe drei LMS-Plattformen in den letzten zwei Jahren gebaut. Zwei davon verwendeten vorgefertigte Lösungen, die wir später entfernten. Die dritte -- diejenige, die tatsächlich funktionierte -- war eine Headless-Architektur, die auf Next.js und Supabase basiert. Diese werde ich dir jetzt zum Aufbau zeigen.

Das Konzept eines Headless-LMS ist einfach: Kommerzielle Plattformen wie Teachable, Thinkific oder sogar Moodle bieten dir eine monolithische Erfahrung, bei der das Content-Modell, das Frontend, das Auth-System und der Zahlungsfluss alle zusammengeschweißt sind. In dem Moment, in dem du etwas Individuelles brauchst -- rollenbasierte Content-Gating, KI-gestützte Quiz-Generierung, white-labelierte Multi-Tenant-Deployments -- kämpfst du gegen die Plattform statt Features zu bauen.

Mit Supabase, das deine Datenbank, Auth, Storage und Echtzeit-Subscriptions verwaltet, und Next.js, das dein Rendering und deine API-Layer handhabt, bekommst du ein System, das wirklich dir gehört. Keine Vendor Lock-in auf der Content-Seite. Vollständige Kontrolle über die Lernererfahrung. Und eine Postgres-Datenbank, die du direkt abfragen kannst, ohne einen HTTP-Roundtrip von Server Components zu machen.

Lass uns es bauen.

Inhaltsverzeichnis

Baue ein Headless-LMS mit Supabase und Next.js in 2026

Warum ein Headless-LMS verwenden

Traditionelle LMS-Plattformen wurden nicht für das moderne Web gebaut. Sie wurden für IT-Abteilungen gebaut, die ein Compliance-Kästchen abhaken wollten. Die Benutzererfahrung spiegelt das wider.

Ein Headless-LMS trennt dein Content-Management und deine Business Logic von deiner Presentation Layer. Das bedeutet:

  • Dein Frontend kann alles sein. Eine Next.js-Web-App, eine React Native Mobile App, ein Slack-Bot, der Mikro-Lektionen bereitstellt. Dieselbe API bedient sie alle.
  • Dein Content-Modell gehört dir. Du definierst, was ein "Kurs" ist. Vielleicht ist es eine Serie von Markdown-Lektionen. Vielleicht ist es eine Sammlung von Video-Modulen mit eingebetteten interaktiven Übungen. Du bist nicht auf das beschränkt, was Moodle denkt, was ein Kurs sein sollte.
  • Du kontrollierst die Daten. Lerner-Fortschritt, Bewertungsergebnisse, Engagement-Metriken -- alles lebt in deiner Postgres-Datenbank. Keine CSVs aus dem Vendor-Dashboard exportieren.

Der Tradeoff ist offensichtlich: Du musst mehr selbst bauen. Aber mit Supabase, das Auth, Storage, Echtzeit und die Database Layer handhabt, ist die Menge an Custom Code, die du tatsächlich schreibst, überraschend klein.

Architekturübersicht

Hier ist die übergeordnete Architektur für das, was wir bauen:

┌─────────────────────────────────────────────┐
│                 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)│
                 └───────────┘

Die wichtigsten architektonischen Entscheidungen:

Entscheidung Wahl Begründung
Rendering Server Components + ISR Course-Katalog-Seiten sind größtenteils statisch; Lerner-Dashboards benötigen frische Daten
Auth Supabase Auth mit PKCE Built-in RLS-Integration, kein separater Auth-Service
Datenbank Supabase Postgres Direkte Abfragen von Server Components, kein ORM nötig
File Storage Supabase Storage Signed URLs für Video-Inhalte, RLS-gesteuerten Zugriff
Echtzeit Supabase Realtime Diskussionsforen, Live-Fortschritt-Updates
Zahlungen Stripe (via Webhooks) Supabase Edge Functions verarbeiten Webhook-Verarbeitung
Content-Format MDX in der Datenbank gespeichert Strukturierter Inhalt, der in React-Komponenten rendert

Server Components in Next.js 15 können Supabase direkt mit dem Server-Client abfragen -- keine API Route nötig, keine Serialization Boundary. Das ist ein massiver Vorteil für ein LMS, bei dem du auf jedem Seitenaufruf Course Data, User Progress und Enrollment Status abrufst.

Datenbankschema-Design

Das Schema ist das Rückgrat jedes LMS. Ich bin durch genug Iterationen gegangen, um zu wissen, dass es sich hier richtig zu machen, dir Wochen spart. Hier ist ein Schema, das Multi-Tenant-Kurse, strukturierte Module, Progress-Tracking und Bewertungen handhabt.

-- Organizations (für 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-Profile, erweitert 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()
);

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

-- Module (Abschnitte innerhalb eines Kurses)
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()
);

-- Lektionen (einzelne 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-Versuche
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()
);

Ein paar Design-Bemerkungen:

  • Die content-Spalte auf Lektionen ist JSONB. Das ist Absicht. Eine Video-Lektion speichert {"video_url": "...", "transcript": "..."}. Eine Text-Lektion speichert {"mdx": "..."}. Ein Quiz speichert {"questions": [...]}. Das gibt dir strukturierte Daten, ohne dass du für jeden Content-Typ eine separate Tabelle brauchst.
  • sort_order auf Modulen und Lektionen. Drag-and-Drop Umordnung ist ein Muss für jeden Course Builder. Integer Sort Orders machen das trivial.
  • Die metadata JSONB auf Kursen. Das ist dein Fluchtweg. SEO-Felder, Custom Branding, Feature Flags -- wirf alles hier rein, ohne Schema Migrations.

Baue ein Headless-LMS mit Supabase und Next.js in 2026 - Architektur

Einrichten der Supabase-Auth mit rollenbasiertem Zugriff

Supabase Auth gibt dir E-Mail/Passwort, OAuth und Magic Links out of the box. Für ein LMS brauchst du typischerweise drei Rollen: Admin, Instructor und Learner. Der Trick besteht darin, Supabase's auth.users mit deiner profiles-Tabelle zu synchronisieren.

Richte zunächst einen Datenbank-Trigger ein, der ein Profil erstellt, wann immer sich ein neuer Benutzer anmeldet:

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

Nun konfiguriere auf der Next.js-Seite den Supabase SSR Client. Das ist das 2026er-Muster mit @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 {
            // Aufgerufen von einer Server Component -- ignorieren
          }
        },
      },
    }
  )
}
// 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!
  )
}

Der Server-Client liest Auth-Token automatisch aus Cookies. In einer Server Component kannst du das tun:

// 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>Willkommen zurück, {profile?.full_name}</h1>
      {/* Render eingeschriebene Kurse */}
    </div>
  )
}

Keine API Route. Kein Fetch-Aufruf. Direkte Datenbankabfrage aus einer Server Component. Das ist der architektonische Vorteil von Supabase + Next.js App Router.

Row Level Security für Multi-Tenant-Inhalte

RLS ist das, was diese Architektur tatsächlich sicher macht. Ohne es würde dein anon Key jedem Zugriff auf alles geben. Mit RLS erzwingt die Datenbank selbst, wer was sehen und ändern kann.

Hier sind die Richtlinien für die Core-Tabellen:

-- Kurse: Jeder kann veröffentlichte Kurse lesen, Instructoren können ihre eigenen verwalten
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Veröffentlichte Kurse sind für alle sichtbar" ON courses
  FOR SELECT USING (status = 'published');

CREATE POLICY "Instructoren verwalten ihre Kurse" ON courses
  FOR ALL USING (
    auth.uid() = instructor_id
  );

CREATE POLICY "Admins verwalten Org-Kurse" ON courses
  FOR ALL USING (
    EXISTS (
      SELECT 1 FROM profiles
      WHERE id = auth.uid()
      AND role = 'admin'
      AND organization_id = courses.organization_id
    )
  );

-- Lektionen: Eingeschriebene Benutzer können lesen, kostenlose Vorschauversionen sind für alle sichtbar
ALTER TABLE lessons ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Kostenlose Vorschau-Lektionen sind für alle sichtbar" ON lessons
  FOR SELECT USING (is_free_preview = true);

CREATE POLICY "Eingeschriebene Benutzer können Lektionen lesen" 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: Benutzer können nur ihren eigenen Progress sehen und aktualisieren
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Benutzer verwalten ihren eigenen Progress" ON lesson_progress
  FOR ALL USING (auth.uid() = user_id);

-- Enrollments: Benutzer sehen ihre eigenen, Instructoren sehen ihre Course Enrollments
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Benutzer sehen ihre eigenen Enrollments" ON enrollments
  FOR SELECT USING (auth.uid() = user_id);

CREATE POLICY "Instructoren sehen Course Enrollments" ON enrollments
  FOR SELECT USING (
    EXISTS (
      SELECT 1 FROM courses
      WHERE courses.id = enrollments.course_id
      AND courses.instructor_id = auth.uid()
    )
  );

Eine Sache, die Leute verwirrt: RLS-Richtlinien sind additiv für SELECT. Wenn eine Richtlinie erfolgreich ist, ist die Zeile sichtbar. Für INSERT, UPDATE und DELETE müssen alle anwendbaren Richtlinien erfolgreich sein. Behalte das in Gedanken, wenn du Zugriffsprobleme debuggst.

Aufbau des Next.js App Router Frontend

Hier ist die Route-Struktur, die ich für ein LMS als funktionierend empfunden habe:

app/
├── (marketing)/
│   ├── page.tsx              # Landing Page
│   ├── courses/
│   │   ├── page.tsx           # Course-Katalog
│   │   └── [slug]/
│   │       └── page.tsx       # Course-Detail / Sales Page
├── (app)/
│   ├── layout.tsx             # Authentifiziertes Layout mit Sidebar
│   ├── dashboard/
│   │   └── page.tsx           # Lerner-Dashboard
│   ├── learn/
│   │   └── [courseSlug]/
│   │       └── [lessonId]/
│   │           └── page.tsx   # Lesson-Viewer
│   ├── instructor/
│   │   ├── courses/
│   │   │   ├── page.tsx       # Instructor-Kursliste
│   │   │   ├── new/
│   │   │   │   └── page.tsx   # Kurs erstellen
│   │   │   └── [id]/
│   │   │       └── edit/
│   │   │           └── page.tsx  # Course-Editor
├── api/
│   └── webhooks/
│       └── stripe/
│           └── route.ts       # Stripe-Webhook-Handler

Die Route Groups (marketing) und (app) lassen dich verschiedene Layouts verwenden, ohne die URL-Struktur zu beeinflussen. Marketing-Seiten erhalten einen minimalen Header und Footer. Der App-Bereich erhält eine vollständige Sidebar mit Course Navigation.

Hier ist eine Server Action zum Einschreiben in einen kostenlosen Kurs:

// 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: 'Du musst angemeldet sein, um dich einzuschreiben' }
  }

  // Überprüfe, ob der Kurs kostenlos ist
  const { data: course } = await supabase
    .from('courses')
    .select('price_cents')
    .eq('id', courseId)
    .single()

  if (course?.price_cents && course.price_cents > 0) {
    return { error: 'Dieser Kurs erfordert Zahlung' }
  }

  const { error } = await supabase
    .from('enrollments')
    .insert({ user_id: user.id, course_id: courseId })

  if (error) {
    if (error.code === '23505') {
      return { error: 'Du bist bereits in diesem Kurs eingeschrieben' }
    }
    return { error: error.message }
  }

  revalidatePath('/dashboard')
  return { success: true }
}

Server Actions in Next.js 15 sind die sauberste Art, Mutationen zu handhaben. Keine API Routes, keine manuellen Fetch-Aufrufe. Das Formular ruft einfach die Funktion auf.

Course-Content-Rendering und Progress-Tracking

Für den Lesson Viewer brauchst du zwei Dinge, die gleichzeitig passieren: Rendering des Inhalts und Progress-Tracking. Hier ist mein Ansatz:

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

  // Hole alle Lektionen in diesem Modul zur 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')

  // Hole aktuellen 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 mit Lektionenliste */}
      </aside>
      <main className="flex-1 overflow-y-auto">
        <LessonViewer
          lesson={lesson}
          progress={progress}
        />
        <ProgressTracker
          lessonId={lessonId}
          initialProgress={progress?.progress_pct ?? 0}
        />
      </main>
    </div>
  )
}

Der ProgressTracker ist eine Client Component, die den Progress aktualisiert, während der Benutzer durch Text-Inhalte scrollt oder Video anschaut. Es nutzt den Browser Supabase-Client, um den Progress in Echtzeit zu schreiben:

// 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 // Diese Component hat keine UI
}

Das Debouncing der Progress-Updates ist kritisch. Du willst nicht die Datenbank bei jedem Scroll-Event treffen.

Echtzeit-Features: Diskussionen und Benachrichtigungen

Supabase Realtime verwandelt dein LMS von einem statischen Content-Viewer in etwas, das sich lebendig anfühlt. Die zwei Features, die ich immer zuerst baue: Lesson-Diskussionen und Progress-Benachrichtigungen für Instructoren.

// 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(() => {
    // Hole existierende Nachrichten
    supabase
      .from('discussions')
      .select('*, profile:profiles(full_name, avatar_url)')
      .eq('lesson_id', lessonId)
      .order('created_at')
      .then(({ data }) => setMessages(data ?? []))

    // Abonniere neue Nachrichten
    const channel = supabase
      .channel(`lesson-${lessonId}`)
      .on(
        'postgres_changes',
        {
          event: 'INSERT',
          schema: 'public',
          table: 'discussions',
          filter: `lesson_id=eq.${lessonId}`,
        },
        async (payload) => {
          // Hole die vollständige Nachricht mit 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 Nachrichten und Eingabeformular */}
    </div>
  )
}

Video- und Datei-Storage mit Supabase Storage

Für Video-Inhalte ist Supabase Storage mit Signed URLs die richtige Wahl. Erstelle einen privaten Bucket für Course-Videos und nutze RLS-ähnliche Richtlinien auf der Storage-Layer:

-- Storage-Bucket-Richtlinie: Nur eingeschriebene Benutzer können Course-Videos zugreifen
CREATE POLICY "Eingeschriebene Benutzer können Course-Videos anschauen"
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 || '/%'
  )
);

Auf der Server-Seite generiere kurzlebige Signed URLs:

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

Für Production wirst du ein CDN vor diese Stelle setzen wollen. Supabase's eingebautes CDN handhabt die meisten Fälle, aber für hochfrequentes Video-Streaming, erwäge Transcoding mit einem Service wie Mux oder Cloudflare Stream und speichere einfach die Playback-IDs in deiner Datenbank.

Deployment und Performance-Überlegungen

Der Supabase Free-Plan gibt dir 500MB Datenbank, 1GB Storage und 50.000 monatlich aktive Benutzer. Das reicht zum Starten. Der Pro-Plan bei $25/Monat gibt dir 8GB Datenbank, 100GB Storage und kein MAU-Limit -- was einen seriösen LMS mit Tausenden von Lernern handhabt.

Supabase Plan DB Storage File Storage MAU Limit Preis
Free 500MB 1GB 50.000 $0
Pro 8GB 100GB Unlimited $25/mo
Team 8GB 100GB Unlimited $599/mo
Enterprise Custom Custom Unlimited Custom

Für Next.js Deployment ist Vercel die offensichtliche Wahl -- du bekommst automatisches ISR, Edge Middleware und Image Optimization. Aber wenn du kostensbewusst bist, kannst du auf Coolify oder Railway für wesentlich weniger deployen.

Performance-Tipps aus Production:

  • Nutze ISR für Course-Katalog-Seiten. revalidate: 3600 bedeutet, dein Katalog wird höchstens einmal pro Stunde neu gebaut. Schnell genug für die meisten Fälle.
  • Nutze Server Components für den Lesson-Viewer. Der initialen Seitenaufruf wird SSR'd mit allem Content. Keine Loading Spinners.
  • Füge Datenbank-Indizes hinzu. Mindestens: enrollments(user_id), lesson_progress(user_id, lesson_id), lessons(module_id, sort_order), courses(organization_id, slug).
  • Connection Pooling. Nutze Supabase's eingebautes PgBouncer (die Pooling-Verbindungszeichenkette) für Server-seitige Abfragen. Die direkte Verbindung für Migrationen.

Wenn du etwas Ähnliches baust und Hilfe bei der Architektur oder Implementierung brauchst, hilft unser Team mit Next.js Development und Headless CMS-Builds regelmäßig. Wir haben mehrere Supabase-basierte Anwendungen versandt und kennen, wo die scharfen Kanten sind.

FAQ

Kann Supabase die Skalierung eines Production-LMS handhaben?

Ja. Supabase läuft auf verwalteten Postgres, das Millionen von Zeilen ohne Probleme handhabt. Der Pro-Plan beinhaltet Connection Pooling via PgBouncer, und du kannst Compute unabhängig skalieren. Zu Referenzzwecken berichten Supabase-Kunden von 100k+ gleichzeitigen Verbindungen auf ihrem Enterprise-Tier. Für die meisten LMS-Anwendungsfälle -- sogar mit Zehntausenden von aktiven Lernern -- ist der Pro-Plan bei $25/Monat mehr als ausreichend.

Wie handhabst du Video-Hosting in einem Supabase-basierten LMS?

Supabase Storage funktioniert zum Speichern und Bereitstellen von Videodateien mit Signed URLs, aber es ist keine Video-Plattform. Für Production-LMS-Video empfehle ich, einen dedizierten Video-Service wie Mux ($0.007/min zum Kodieren, $0.00015/sec zum Bereitstellen) oder Cloudflare Stream ($1/1000 min gespeichert, $5/1000 min bereitgestellt) zu verwenden. Speichere die Playback-IDs in deiner Supabase-Datenbank und nutze ihre Player-SDKs auf dem Frontend. Das gibt dir adaptive Bitrate, Analytics und DRM, ohne etwas davon selbst zu bauen.

Ist Next.js App Router stabil genug für ein Production-LMS in 2026?

Absolut. Der App Router ist seit Next.js 14 stabil, und Next.js 15 beseitigte die verbleibenden rauen Kanten mit Server Actions und Caching. Server Components sind ein genuiner architektonischer Vorteil für datenintensive Anwendungen wie ein LMS. Die after()-API in Next.js 15 ist besonders nützlich, um Analytics-Ereignisse und Progress-Updates auszulösen, ohne die Response zu blockieren.

Wie implementierst du Course-Abschluss-Zertifikate?

Verfolge Abschluss auf der Lektionen-Ebene mit lesson_progress, dann berechne Course-Abschluss als Prozentsatz der abgeschlossenen Lektionen in allen Modulen. Wenn ein Benutzer 100% erreicht, löse eine Supabase Edge Function (oder eine Next.js Server Action) aus, die ein PDF-Zertifikat mit einer Bibliothek wie @react-pdf/renderer generiert und in Supabase Storage speichert. Füge eine Zeile zu einer certificates-Tabelle mit dem Storage-Pfad und einem eindeutigen Verifizierungscode hinzu.

Was ist mit SCORM-Compliance?

SCORM ist ein Legacy-Standard, von dem sich die meisten modernen LMS-Plattformen entfernen. Das gesagt, wenn du SCORM-Unterstützung brauchst -- typischerweise für Corporate-Compliance-Training -- kannst du SCORM-Pakete parsen und den extrahierten Inhalt in deiner Supabase-Datenbank speichern. Bibliotheken wie pipwerks-scorm-api-wrapper handhaben die Runtime-API. Es ist machbar, aber fügt erhebliche Komplexität hinzu. Ich würde es vermeiden, es sei denn, ein Kunde fordert es spezifisch.

Wie handhabst du Zahlungen für kostenpflichtige Kurse?

Stripe Checkout ist der einfachste Weg. Erstelle eine Stripe Checkout-Session von einer Server Action, leite den Benutzer zu Stripe um und handhabe den checkout.session.completed Webhook in einer Next.js API Route oder Supabase Edge Function. Der Webhook erstellt den Enrollment-Datensatz. Das hält deine Zahlungslogik einfach und PCI-konform, ohne Kreditkartendaten selbst zu handhaben.

Kann diese Architektur auch eine Mobile App unterstützen?

Das ist einer der größten Vorteile, Headless zu werden. Deine Supabase-Datenbank und Auth funktionieren identisch von einer React Native App über @supabase/supabase-js. Dieselben RLS-Richtlinien schützen die Daten. Du baust einfach eine andere UI-Layer. Wir haben Teams gesehen, die Web und Mobile vom gleichen Supabase-Backend versenden, ohne API-Änderungen.

Wie fügst du KI-Features wie Quiz-Generierung oder Content-Zusammenfassung hinzu?

Speichere deinen Lesson-Inhalt als strukturiertes JSONB (nicht HTML-Blobs). Erstelle dann eine Supabase Edge Function, die Lesson-Inhalte zu einem LLM (OpenAI, Anthropic oder ein selbst-gehostetes Modell) sendet, um Quiz-Fragen, Zusammenfassungen oder Lernanleitungen zu generieren. Speichere den generierten Inhalt zurück in JSONB-Felder. Das strukturierte Data-Modell, das wir früher designed haben, macht das unkompliziert -- KI-Systeme können spezifische Felder konsumieren, ohne Markup zu parsen. Wenn du an dieser Art von Arbeit interessiert bist, kontaktiere unser Team -- wir haben all das Jahr KI-Integrationen in Content-Plattformen gebaut.