Ik heb de afgelopen twee jaar drie LMS-platforms gebouwd. Twee daarvan gebruikten kant-en-klare oplossingen die we uiteindelijk hebben verwijderd. De derde -- degene die echt werkte -- was een headless architecture gebouwd op Next.js en Supabase. Dat is degene die ik je door ga nemen.

De pitch voor een headless LMS is eenvoudig: commerciële platforms zoals Teachable, Thinkific of zelfs Moodle geven je een monolithische ervaring waarbij het contentmodel, de frontend, het authentificatiesysteem en de betalingsstroom allemaal aan elkaar gelast zijn. Op het moment dat je iets aangepasts nodig hebt -- op rollen gebaseerde inhoudsblokkering, door AI gegenereerde quizvragen, white-label multi-tenant implementaties -- vecht je tegen het platform in plaats van functies te bouwen.

Met Supabase dat je database, auth, opslag en real-time abonnementen regelt, en Next.js dat je rendering en API-laag regelt, krijg je een systeem dat echt van jou is. Geen vendor lock-in aan de contentkant. Volledige controle over de leerlingervaring. En een Postgres-database die je rechtstreeks kan bevragen zonder een HTTP round-trip vanuit server components.

Laten we het bouwen.

Inhoudsopgave

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

Waarom Headless voor een LMS

Traditionele LMS-platforms waren niet ontworpen voor het moderne web. Ze waren ontworpen voor IT-afdelingen die een compliancevinkje wilden zetten. De gebruikerservaring weerspiegelt dat.

Een headless LMS scheidt je contentbeheer en bedrijfslogica van je presentatielaag. Dit betekent:

  • Je frontend kan alles zijn. Een Next.js web-app, een React Native mobiele app, een Slack bot die microlessons aflevert. Dezelfde API bedient ze allemaal.
  • Je contentmodel is van jou. Je bepaalt wat een "course" is. Misschien is het een serie markdown-lessen. Misschien is het een verzameling videomodules met ingebedde interactieve oefeningen. Je bent niet beperkt door wat Moodle denkt dat een course zou moeten zijn.
  • Je controleert de data. Voortgang van leerlingen, evaluatieresultaten, betrokkenheidsmetrieken -- het leeft allemaal in je Postgres-database. Geen CSV's exporteren uit een vendor dashboard.

De trade-off is duidelijk: je moet meer zelf bouwen. Maar met Supabase dat auth, opslag, real-time en de databaselaag regelt, is de hoeveelheid aangepaste code die je daadwerkelijk schrijft verrassend klein.

Architectuuroverzicht

Hier is de architectuur op hoog niveau voor wat we gaan bouwen:

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

De sleutelarchitectuurbeslissingen:

Beslissing Keuze Rationale
Rendering Server Components + ISR Course cataloguspagina's zijn meestal statisch; leerlingdashboards hebben verse gegevens nodig
Auth Supabase Auth met PKCE Ingebouwde RLS-integratie, geen afzonderlijke auth-service
Database Supabase Postgres Directe query's vanuit server components, geen ORM nodig
Bestandsopslag Supabase Storage Ondertekende URL's voor video-inhoud, RLS-gated toegang
Real-time Supabase Realtime Discussieforums, live voortgangsupdates
Betalingen Stripe (via webhooks) Supabase Edge Functions verwerken webhook-verwerking
Contentformat MDX opgeslagen in DB Gestructureerde inhoud die in React-componenten wordt weergegeven

Server components in Next.js 15 kunnen Supabase rechtstreeks opvragen met behulp van de server client -- geen API route nodig, geen serialisatiegrenzen. Dit is een enorm voordeel voor een LMS waar je coursegegevens, voortgang van gebruikers en inschrijvingsstatus op elke pagina laadt.

Databaseschema-ontwerp

Het schema is de ruggengraat van elke LMS. Ik ben genoeg iteraties doorlopen om te weten dat dit goed doen in het begin je weken later bespaard. Hier is een schema dat multi-tenant courses, gestructureerde modules, voortgangsvolgeling en evaluaties regelt.

-- Organizations (voor 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 met uitbreiding van 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 (secties binnen een 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 (individuele 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)
);

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

Een paar ontwerpopmerkingen:

  • De content-kolom op lessons is JSONB. Dit is opzettelijk. Een videoles slaat {"video_url": "...", "transcript": "..."} op. Een tekstles slaat {"mdx": "..."} op. Een quiz slaat {"questions": [...]} op. Dit geeft je gestructureerde gegevens zonder dat je een afzonderlijke tabel voor elk contenttype nodig hebt.
  • sort_order op modules en lessons. Drag-and-drop herschikken is een must voor elke course builder. Integer sort orders maken dit triviaal.
  • De metadata JSONB op courses. Dit is je vluchtroute. SEO-velden, custom branding, feature flags -- gooi het allemaal hier in zonder schema-migraties.

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

Supabase Auth instellen met op rollen gebaseerde toegang

Supabase Auth geeft je email/wachtwoord, OAuth en magische links uit de doos. Voor een LMS heb je doorgaans drie rollen nodig: admin, instructor en learner. De trick is Supabase's auth.users synchroniseren met je profiles-tabel.

Stel eerst een database-trigger in die een profiel maakt wanneer een nieuwe gebruiker zich aanmeldt:

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

Configureer nu aan de Next.js-zijde de Supabase SSR-client. Dit is het 2026-patroon met behulp van @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!
  )
}

De server client leest auth tokens automatisch uit cookies. In een server component kun je dit doen:

// 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>Welkom terug, {profile?.full_name}</h1>
      {/* Render enrolled courses */}
    </div>
  )
}

Geen API route. Geen fetch-aanroep. Directe databasequery vanuit een server component. Dit is het architecturale voordeel van Supabase + Next.js App Router.

Row Level Security voor multi-tenant content

RLS is wat deze architectuur daadwerkelijk veilig maakt. Zonder het zou je anon key iedereen toegang geven tot alles. Met RLS, afdwingt de database zelf wie wat kan zien en wijzigen.

Hier zijn de beleidsregels voor de kerntabellen:

-- Courses: iedereen kan gepubliceerde courses zien, instructors beheren hun eigen
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: ingeschreven gebruikers kunnen lezen, gratis voorbeelden zichtbaar voor iedereen
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: gebruikers kunnen alleen hun eigen voortgang zien en bijwerken
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: gebruikers zien hun eigen, instructors zien hun 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()
    )
  );

Eén ding dat mensen voor de voeten loopt: RLS-beleidsregels zijn additief voor SELECT. Als enig beleid slaagt, is de rij zichtbaar. Voor INSERT, UPDATE en DELETE moeten alle toepasselijke beleidsregels slagen. Houd dit in gedachten wanneer je toegangsproblemen debugt.

De Next.js App Router Frontend bouwen

Hier is de routestructuur die ik heb bevonden goed werkt voor een LMS:

app/
├── (marketing)/
│   ├── page.tsx              # Landingspagina
│   ├── courses/
│   │   ├── page.tsx           # Course catalogus
│   │   └── [slug]/
│   │       └── page.tsx       # Course detail / verkooppagina
├── (app)/
│   ├── layout.tsx             # Geverifieerde layout met zijbalk
│   ├── dashboard/
│   │   └── page.tsx           # Leerling-dashboard
│   ├── learn/
│   │   └── [courseSlug]/
│   │       └── [lessonId]/
│   │           └── page.tsx   # Lesson viewer
│   ├── instructor/
│   │   ├── courses/
│   │   │   ├── page.tsx       # Instructor course lijst
│   │   │   ├── new/
│   │   │   │   └── page.tsx   # Course aanmaken
│   │   │   └── [id]/
│   │   │       └── edit/
│   │   │           └── page.tsx  # Course editor
├── api/
│   └── webhooks/
│       └── stripe/
│           └── route.ts       # Stripe webhook handler

De routegroepen (marketing) en (app) laten je verschillende layouts gebruiken zonder de URL-structuur te beïnvloeden. Marketingpagina's krijgen een minimale header en footer. Het app-gedeelte krijgt een volledige zijbalk met course navigatie.

Hier is een server action voor inschrijving in een gratis 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: 'Je moet ingelogd zijn om in te schrijven' }
  }

  // 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: 'Deze course vereist betaling' }
  }

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

  if (error) {
    if (error.code === '23505') {
      return { error: 'Je bent al ingeschreven voor deze course' }
    }
    return { error: error.message }
  }

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

Server actions in Next.js 15 zijn de schoonste manier om mutaties af te handelen. Geen API routes, geen handmatige fetch-aanroepen. Het formulier roept gewoon de functie aan.

Course Content Rendering en voortgangsvolgeling

Voor de lesson viewer heb je twee dingen tegelijk nodig: inhoud renderen en voortgang bijhouden. Hier is mijn aanpak:

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

De ProgressTracker is een client component die voortgang bijwerkt als de gebruiker door tekstinhoud scrollt of video bekijkt. Het gebruikt de browser Supabase-client om voortgang in real-time te schrijven:

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

Het debounce-n van de voortgangsupdates is kritiek. Je wilt niet bij elke scroll event de database raken.

Real-Time Functies: Discussies en Meldingen

Supabase Realtime verandert je LMS van een static content viewer in iets wat zich levend aanvoelt. De twee functies die ik altijd eerst bouw: lessondiscussies en voortgangsmeldingen voor 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 en bestandsopslag met Supabase Storage

Voor video-inhoud is Supabase Storage met ondertekende URL's de weg naar voren. Maak een prive-bucket voor course video's en gebruik RLS-achtige beleidsregels op de storage-laag:

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

Aan de server-zijde genereer je kortdurende ondertekende URL's:

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

Voor productie wil je een CDN voor dit zetten. Supabase's ingebouwde CDN regelt de meeste gevallen, maar voor video's met veel verkeer, overweeg transcoding met een service zoals Mux of Cloudflare Stream en sla alleen de playback ID's op in je database.

Implementatie en prestatieoverwegingen

De Supabase gratis tier geeft je 500MB database, 1GB opslag en 50.000 maandelijks actieve gebruikers. Dat is genoeg om te lanceren. Het Pro-plan op $25/maand geeft je 8GB database, 100GB opslag en geen MAU-limiet -- wat een serieuze LMS met duizenden leerlingen afhandelt.

Supabase Plan DB Opslag Bestandsopslag MAU Limiet Prijs
Free 500MB 1GB 50.000 $0
Pro 8GB 100GB Onbeperkt $25/mnd
Team 8GB 100GB Onbeperkt $599/mnd
Enterprise Custom Custom Onbeperkt Custom

Voor Next.js-implementatie is Vercel de duidelijke keuze -- je krijgt automatische ISR, edge middleware en image optimization. Maar als je kostensbewust bent, kun je op Coolify of Railway implementeren voor aanzienlijk minder.

Prestatietips uit productie:

  • Gebruik ISR voor course cataloguspagina's. revalidate: 3600 betekent je catalogus wordt op zijn hoogst eenmaal per uur herbouwd. Snel genoeg voor de meeste gevallen.
  • Gebruik server components voor de lesson viewer. De initiële pagina load wordt SSR'd met alle inhoud. Geen laadspinners.
  • Voeg database indexen toe. Op zijn minst: enrollments(user_id), lesson_progress(user_id, lesson_id), lessons(module_id, sort_order), courses(organization_id, slug).
  • Connection pooling. Gebruik Supabase's ingebouwde PgBouncer (de pooling connection string) voor server-side queries. De directe verbinding voor migraties.

Als je iets dergelijks bouwt en wil helpen bij de architectuur of implementatie, doet ons team Next.js development en headless CMS builds regelmatig. We hebben verschillende Supabase-backed applicaties verzonden en weten waar de scherpe hoeken zijn.

Veelgestelde vragen

Kan Supabase de schaal van een productie-LMS aan? Ja. Supabase draait op managed Postgres, wat miljoenen rijen zonder problemen afhandelt. Het Pro-plan bevat connection pooling via PgBouncer, en je kunt compute onafhankelijk schalen. Ter referentie: Supabase rapporteert klanten die 100k+ gelijktijdige verbindingen op hun Enterprise tier draaien. Voor de meeste LMS use cases -- zelfs met tienduizenden actieve leerlingen -- is het Pro-plan op $25/maand meer dan genoeg.

Hoe handel je video hosting af in een Supabase-gebaseerde LMS? Supabase Storage werkt voor het opslaan en bedienen van videobestanden met ondertekende URL's, maar het is geen videoplatform. Voor productie LMS video's aanbevelen ik het gebruik van een speciale videoservice zoals Mux ($0,007/min voor encoding, $0,00015/sec voor aflevering) of Cloudflare Stream ($1/1000 min opgeslagen, $5/1000 min afgeleverd). Sla de playback ID's in je Supabase-database op en gebruik hun player SDK's op de frontend. Dit geeft je adaptieve bitrate, analytics en DRM zonder iets hiervan zelf te bouwen.

Is Next.js App Router stabiel genoeg voor een productie-LMS in 2026? Absoluut. De App Router is stabiel sinds Next.js 14, en Next.js 15 heeft de resterende ruwe hoeken glad gestreken met server actions en caching. Server components zijn een genuine architecturaal voordeel voor data-zware applicaties zoals een LMS. De after()-API in Next.js 15 is bijzonder nuttig voor het afvuren van analytics events en voortgangsupdates zonder de response te blokkeren.

Hoe implementeer je courseafsluitingscertificaten? Houd afsluitng op lesniveau bij met lesson_progress, bereken vervolgens course completion als een percentage voltooide lessen in alle modules. Wanneer een gebruiker 100% bereikt, activeer je een Supabase Edge Function (of een Next.js server action) die een PDF-certificaat genereert met behulp van een bibliotheek zoals @react-pdf/renderer en sla het op in Supabase Storage. Voeg een rij toe aan een certificates-tabel met het storage pad en een unieke verificatiecode.

Wat te doen met SCORM-compliance? SCORM is een verouderde standaard waar de meeste moderne LMS-platforms vandaan gaan. Dat gezegd hebbende, als je SCORM-ondersteuning nodig hebt -- typisch voor corporate compliance training -- kun je SCORM-pakketten parseren en de geëxtraheerde inhoud opslaan in je Supabase-database. Bibliotheken zoals pipwerks-scorm-api-wrapper verwerken de runtime API. Het is haalbaar maar voegt significant complexiteit toe. Ik zou het vermijden tenzij een klant het specifiek verlangt.

Hoe handel je betalingen voor betaalde courses af? Stripe Checkout is het eenvoudigste pad. Maak een Stripe Checkout-sessie aan vanuit een server action, stuur de gebruiker naar Stripe en handel de checkout.session.completed-webhook af in een Next.js API route of Supabase Edge Function. De webhook maakt de enrollment-record. Dit houdt je betalingslogica eenvoudig en PCI-compliant zonder creditcardgegevens zelf af te handelen.

Kan deze architectuur ook een mobiele app ondersteunen? Dat is een van de grootste voordelen van het gaan naar headless. Je Supabase-database en auth werken identiek vanuit een React Native app via @supabase/supabase-js. Dezelfde RLS-beleidsregels beschermen de gegevens. Je bouwt alleen een ander UI-laag. We hebben teams zien verzenden web en mobiel vanuit dezelfde Supabase-backend zonder API-wijzigingen.

Hoe voeg je AI-functies toe zoals quizgenerering of content-samenvatting? Sla je lesinhoud op als gestructureerde JSONB (geen HTML-blobs). Maak vervolgens een Supabase Edge Function die lesinhoud naar een LLM stuurt (OpenAI, Anthropic, of een zelf gehost model) om quizvragen, samenvattingen of studiehandleidingen te genereren. Sla de gegenereerde inhoud terug op in JSONB-velden. Het gestructureerde datamodel dat we eerder hebben ontworpen maakt dit eenvoudig -- AI-systemen kunnen specifieke velden consumeren zonder markup te parseren. Als je interesse hebt in dit soort werk, neem contact op met ons team -- we hebben het hele jaar AI-integraties in contentplatformen gebouwd.