J'ai construit trois plateformes LMS en deux ans. Deux d'entre elles utilisaient des solutions prêtes à l'emploi que nous avons finalement supprimées. La troisième -- celle qui a réellement fonctionné -- était une architecture headless construite sur Next.js et Supabase. C'est celle que je vais vous montrer comment construire.

L'argument pour un LMS headless est simple : les plateformes commerciales comme Teachable, Thinkific, ou même Moodle vous donnent une expérience monolithique où le modèle de contenu, le frontend, le système d'authentification et le flux de paiement sont tous soudés ensemble. Au moment où vous avez besoin de quelque chose de personnalisé -- gating de contenu basé sur les rôles, génération de quiz alimentée par l'IA, déploiements multi-locataires en marque blanche -- vous vous battez contre la plateforme au lieu de construire des fonctionnalités.

Avec Supabase gérant votre base de données, l'authentification, le stockage et les abonnements en temps réel, et Next.js gérant votre couche de rendu et API, vous obtenez un système qui est vraiment vôtre. Aucun verrouillage au fournisseur du côté du contenu. Contrôle total sur l'expérience de l'apprenant. Et une base de données Postgres que vous pouvez interroger directement sans aller-retour HTTP depuis les composants serveur.

Construisons-le.

Table des matières

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

Pourquoi aller headless pour un LMS

Les plateformes LMS traditionnelles n'ont pas été construites pour le web moderne. Elles ont été construites pour les départements informatiques qui voulaient cocher une case de conformité. L'expérience utilisateur reflète cela.

Un LMS headless sépare la gestion de votre contenu et la logique métier de votre couche de présentation. Cela signifie :

  • Votre frontend peut être n'importe quoi. Une application web Next.js, une application mobile React Native, un bot Slack qui dispense des micro-leçons. La même API les sert tous.
  • Votre modèle de contenu vous appartient. Vous définissez ce qu'est un « cours ». Peut-être c'est une série de leçons markdown. Peut-être c'est une collection de modules vidéo avec des exercices interactifs intégrés. Vous n'êtes pas limité par ce que Moodle pense qu'un cours devrait être.
  • Vous contrôlez les données. La progression des apprenants, les résultats des évaluations, les métriques d'engagement -- tout cela vit dans votre base de données Postgres. Pas d'export de CSV à partir du tableau de bord du fournisseur.

Le compromis est évident : vous devez construire plus vous-même. Mais avec Supabase gérant l'authentification, le stockage, le temps réel et la couche base de données, la quantité de code personnalisé que vous écrivez réellement est étonnamment petite.

Aperçu de l'architecture

Voici l'architecture de haut niveau pour ce que nous construisons :

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

Les décisions architecturales clés :

Décision Choix Justification
Rendu Composants serveur + ISR Les pages du catalogue de cours sont principalement statiques ; les tableaux de bord des apprenants ont besoin de données actualisées
Authentification Supabase Auth avec PKCE Intégration RLS intégrée, aucun service d'authentification séparé
Base de données Supabase Postgres Requêtes directes depuis les composants serveur, aucun ORM nécessaire
Stockage de fichiers Supabase Storage URLs signées pour le contenu vidéo, accès contrôlé par RLS
Temps réel Supabase Realtime Forums de discussion, mises à jour de progression en direct
Paiements Stripe (via webhooks) Supabase Edge Functions gère le traitement des webhooks
Format de contenu MDX stocké en BD Contenu structuré qui s'affiche dans les composants React

Les composants serveur dans Next.js 15 peuvent interroger Supabase directement à l'aide du client serveur -- aucune route API nécessaire, aucune limite de sérialisation. C'est une énorme victoire pour un LMS où vous récupérez les données du cours, la progression de l'utilisateur et l'état d'inscription à chaque chargement de page.

Conception du schéma de base de données

Le schéma est l'épine dorsale de tout LMS. J'ai traversé assez d'itérations pour savoir que bien faire cela au début vous économise des semaines plus tard. Voici un schéma qui gère les cours multi-locataires, les modules structurés, le suivi de la progression et les évaluations.

-- Organizations (pour multi-locataire / marque blanche)
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()
);

-- Profils utilisateur étendant l'authentification 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()
);

-- Cours
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 dans un cours)
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()
);

-- Leçons (unités de contenu individuelles)
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()
);

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

-- Suivi de la progression
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)
);

-- Tentatives de quiz
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()
);

Quelques remarques sur la conception :

  • La colonne content sur les leçons est JSONB. C'est intentionnel. Une leçon vidéo stocke {"video_url": "...", "transcript": "..."}. Une leçon textuelle stocke {"mdx": "..."}. Un quiz stocke {"questions": [...]}. Cela vous donne des données structurées sans avoir besoin d'une table séparée pour chaque type de contenu.
  • sort_order sur les modules et les leçons. Le réordonnancement par glisser-déposer est indispensable pour tout générateur de cours. Les ordres de tri entiers rendent cela trivial.
  • Le JSONB metadata sur les cours. C'est votre issue de secours. Champs SEO, marque personnalisée, drapeaux de fonctionnalités -- mettez tout ici sans migrations de schéma.

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

Configuration de l'authentification Supabase avec accès basé sur les rôles

Supabase Auth vous donne email/mot de passe, OAuth et magic links prêts à l'emploi. Pour un LMS, vous avez généralement besoin de trois rôles : admin, instructor et learner. L'astuce est de synchroniser les auth.users de Supabase avec votre table profiles.

Tout d'abord, configurez un déclencheur de base de données qui crée un profil à chaque fois qu'un nouvel utilisateur s'inscrit :

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

Maintenant, du côté Next.js, configurez le client SSR Supabase. C'est le motif 2026 en utilisant @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!
  )
}

Le client serveur lit les tokens d'authentification depuis les cookies automatiquement. Dans un composant serveur, vous pouvez faire ceci :

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

Aucune route API. Aucun appel fetch. Requête directe de base de données depuis un composant serveur. C'est l'avantage architectural de Supabase + Next.js App Router.

Sécurité au niveau des lignes pour le contenu multi-locataire

RLS est ce qui rend cette architecture réellement sécurisée. Sans elle, votre clé anon donnerait à quiconque accès à tout. Avec RLS, la base de données elle-même impose qui peut voir et modifier quoi.

Voici les politiques pour les tables principales :

-- Cours : tout le monde peut lire les cours publiés, les instructeurs gèrent les leurs
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
    )
  );

-- Leçons : les utilisateurs inscrits peuvent lire, les aperçus gratuits visibles à tous
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
    )
  );

-- Progression : les utilisateurs ne peuvent voir et mettre à jour que leur propre progression
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Users manage own progress" ON lesson_progress
  FOR ALL USING (auth.uid() = user_id);

-- Inscriptions : les utilisateurs voient les leurs, les instructeurs voient les inscriptions de leur cours
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()
    )
  );

Une chose qui trompe les gens : les politiques RLS sont additives pour SELECT. Si toute politique réussit, la ligne est visible. Pour INSERT, UPDATE et DELETE, toutes les politiques applicables doivent réussir. Gardez cela à l'esprit lors du débogage des problèmes d'accès.

Construction du frontend Next.js App Router

Voici la structure de route qui fonctionne bien pour un LMS :

app/
├── (marketing)/
│   ├── page.tsx              # Page d'accueil
│   ├── courses/
│   │   ├── page.tsx           # Catalogue de cours
│   │   └── [slug]/
│   │       └── page.tsx       # Détail du cours / page de vente
├── (app)/
│   ├── layout.tsx             # Disposition authentifiée avec barre latérale
│   ├── dashboard/
│   │   └── page.tsx           # Tableau de bord de l'apprenant
│   ├── learn/
│   │   └── [courseSlug]/
│   │       └── [lessonId]/
│   │           └── page.tsx   # Visionneuse de leçon
│   ├── instructor/
│   │   ├── courses/
│   │   │   ├── page.tsx       # Liste de cours de l'instructeur
│   │   │   ├── new/
│   │   │   │   └── page.tsx   # Créer un cours
│   │   │   └── [id]/
│   │   │       └── edit/
│   │   │           └── page.tsx  # Éditeur de cours
├── api/
│   └── webhooks/
│       └── stripe/
│           └── route.ts       # Gestionnaire de webhook Stripe

Les groupes de routes (marketing) et (app) vous permettent d'utiliser des dispositions différentes sans affecter la structure d'URL. Les pages marketing obtiennent un en-tête et un pied de page minimalistes. La section app reçoit une barre latérale complète avec navigation du cours.

Voici une action serveur pour s'inscrire à un cours gratuit :

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

Les actions serveur dans Next.js 15 sont le moyen le plus propre de gérer les mutations. Aucune route API, aucun appel fetch manuel. Le formulaire appelle simplement la fonction.

Rendu du contenu du cours et suivi de la progression

Pour la visionneuse de leçon, vous avez besoin de deux choses qui se produisent simultanément : rendre le contenu et suivre la progression. Voici mon approche :

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

Le ProgressTracker est un composant client qui met à jour la progression au fur et à mesure que l'utilisateur fait défiler le contenu textuel ou regarde des vidéos. Il utilise le client Supabase du navigateur pour écrire la progression en temps réel :

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

Le debounce des mises à jour de progression est essentiel. Vous ne voulez pas frapper la base de données à chaque événement de défilement.

Fonctionnalités en temps réel : Discussions et notifications

Supabase Realtime transforme votre LMS d'une visionneuse de contenu statique en quelque chose qui semble vivant. Les deux fonctionnalités que je construis toujours en premier : les discussions de leçon et les notifications de progression pour les instructeurs.

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

Stockage vidéo et fichiers avec Supabase Storage

Pour le contenu vidéo, Supabase Storage avec URLs signées est la solution. Créez un bucket privé pour les vidéos de cours et utilisez des politiques similaires à RLS sur la couche de stockage :

-- Politique de bucket de stockage : seuls les utilisateurs inscrits peuvent accéder aux vidéos de cours
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 || '/%'
  )
);

Du côté serveur, générez des URLs signées de courte durée :

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

Pour la production, vous voudrez mettre un CDN devant ceci. Le CDN intégré de Supabase gère la plupart des cas, mais pour la livraison vidéo à haut trafic, envisagez le transcodage avec un service comme Mux ou Cloudflare Stream et stockez juste les ID de lecture dans votre base de données.

Déploiement et considérations de performance

Le niveau gratuit de Supabase vous donne 500MB de base de données, 1GB de stockage et 50 000 utilisateurs actifs mensuels. C'est suffisant pour lancer. Le plan Pro à 25$/mois vous donne 8GB de base de données, 100GB de stockage et pas de limite MAU -- ce qui gère un LMS sérieux avec des milliers d'apprenants.

Plan Supabase Stockage BD Stockage fichiers Limite MAU Prix
Gratuit 500MB 1GB 50 000 0$
Pro 8GB 100GB Illimité 25$/mois
Équipe 8GB 100GB Illimité 599$/mois
Entreprise Personnalisé Personnalisé Illimité Personnalisé

Pour le déploiement Next.js, Vercel est le choix évident -- vous obtenez ISR automatique, middleware de périphérie et optimisation d'images. Mais si vous êtes sensible aux coûts, vous pouvez déployer sur Coolify ou Railway pour beaucoup moins.

Conseils de performance de la production :

  • Utilisez ISR pour les pages du catalogue de cours. revalidate: 3600 signifie que votre catalogue se reconstruit au maximum une fois par heure. Assez rapide pour la plupart des cas d'utilisation.
  • Utilisez les composants serveur pour la visionneuse de leçon. Le chargement initial de la page est rendu avec tout le contenu. Pas de spinners de chargement.
  • Ajoutez des index de base de données. Au minimum : enrollments(user_id), lesson_progress(user_id, lesson_id), lessons(module_id, sort_order), courses(organization_id, slug).
  • Connexion pooling. Utilisez la chaîne de connexion intégrée de Supabase PgBouncer (la chaîne de connexion pooling) pour les requêtes côté serveur. La connexion directe pour les migrations.

Si vous construisez quelque chose comme ça et voulez de l'aide avec l'architecture ou la mise en œuvre, notre équipe fait du développement Next.js et des constructions CMS headless régulièrement. Nous avons livré plusieurs applications basées sur Supabase et connaissons où se trouvent les arêtes vives.

FAQ

Supabase peut-il gérer l'échelle d'un LMS en production ?

Oui. Supabase s'exécute sur Postgres géré, qui gère des millions de lignes sans casser un vase. Le plan Pro inclut le connection pooling via PgBouncer, et vous pouvez mettre à l'échelle le calcul indépendamment. À titre de référence, Supabase signale des clients exécutant 100 000+ connexions simultanées sur leur niveau Entreprise. Pour la plupart des cas d'utilisation LMS -- même avec des dizaines de milliers d'apprenants actifs -- le plan Pro à 25$/mois est plus que suffisant.

Comment gérez-vous l'hébergement vidéo dans un LMS basé sur Supabase ?

Supabase Storage fonctionne pour stocker et servir des fichiers vidéo avec des URLs signées, mais ce n'est pas une plateforme vidéo. Pour la vidéo LMS de production, je recommande d'utiliser un service vidéo dédié comme Mux (0,007$/min pour l'encodage, 0,00015$/sec pour la livraison) ou Cloudflare Stream (1$/1000 min stockées, 5$/1000 min livrées). Stockez les ID de lecture dans votre base de données Supabase et utilisez leurs SDKs de lecteur sur le frontend. Cela vous donne l'adaptative bitrate, les analyses et DRM sans construire tout cela vous-même.

App Router de Next.js est-il stable suffisant pour un LMS en production en 2026 ?

Absolument. Le App Router est stable depuis Next.js 14, et Next.js 15 a lissé les dernières aspérités avec les actions serveur et le caching. Les composants serveur sont un avantage architectural véritable pour les applications riches en données comme un LMS. L'API after() dans Next.js 15 est particulièrement utile pour déclencher des événements d'analyse et des mises à jour de progression sans bloquer la réponse.

Comment implémentez-vous les certificats d'accomplissement du cours ?

Suivez l'accomplissement au niveau de la leçon avec lesson_progress, puis calculez l'accomplissement du cours en pourcentage de leçons complétées dans tous les modules. Quand un utilisateur atteint 100%, déclenchez une Supabase Edge Function (ou une action serveur Next.js) qui génère un certificat PDF en utilisant une bibliothèque comme @react-pdf/renderer et le stocke dans Supabase Storage. Ajoutez une ligne à une table certificates avec le chemin de stockage et un code de vérification unique.

Qu'en est-il de la conformité SCORM ?

SCORM est une norme héritée que la plupart des plateformes LMS modernes abandonnent. Cela dit, si vous avez besoin du support SCORM -- généralement pour la formation de conformité d'entreprise -- vous pouvez analyser les paquets SCORM et stocker le contenu extrait dans votre base de données Supabase. Les bibliothèques comme pipwerks-scorm-api-wrapper gèrent l'API d'exécution. C'est faisable mais ajoute une complexité significative. Je l'éviterais à moins qu'un client ne l'exige spécifiquement.

Comment gérez-vous les paiements pour les cours payants ?

Stripe Checkout est le chemin le plus simple. Créez une session Stripe Checkout à partir d'une action serveur, redirigez l'utilisateur vers Stripe, et gérez le webhook checkout.session.completed dans une route API Next.js ou Supabase Edge Function. Le webhook crée l'enregistrement d'inscription. Cela maintient votre logique de paiement simple et conforme à PCI sans gérer les données de carte de crédit vous-même.

Cette architecture peut-elle supporter une application mobile aussi ?

C'est l'un des plus grands avantages d'aller headless. Votre base de données Supabase et l'authentification fonctionnent de manière identique depuis une application React Native via @supabase/supabase-js. Les mêmes politiques RLS protègent les données. Vous construisez juste une couche UI différente. Nous avons vu des équipes lancer le web et le mobile à partir du même backend Supabase sans changements API.

Comment ajoutez-vous des fonctionnalités d'IA comme la génération de quiz ou le résumé de contenu ?

Stockez votre contenu de leçon sous forme JSONB structuré (pas de blobs HTML). Créez ensuite une Supabase Edge Function qui envoie le contenu de la leçon à un LLM (OpenAI, Anthropic ou un modèle auto-hébergé) pour générer des questions de quiz, des résumés ou des guides d'étude. Stockez le contenu généré dans les champs JSONB. Le modèle de données structuré que nous avons conçu plus tôt rend cela simple -- les systèmes d'IA peuvent consommer des champs spécifiques sans analyser le markup. Si vous êtes intéressé par ce type de travail, contactez notre équipe -- nous avons construit des intégrations d'IA dans des plateformes de contenu toute l'année.