Construye un LMS Headless con Supabase y Next.js en 2026
Construir un LMS sin cabecera con Supabase y Next.js
He construido tres plataformas LMS en los últimos dos años. Dos de ellas utilizaban soluciones comerciales que eventualmente eliminamos. La tercera -- la que realmente funcionó -- fue una arquitectura sin cabecera construida con Next.js y Supabase. Es la que voy a guiarte a través de la construcción.
La propuesta de un LMS sin cabecera es simple: las plataformas comerciales como Teachable, Thinkific, o incluso Moodle te dan una experiencia monolítica donde el modelo de contenido, el frontend, el sistema de autenticación y el flujo de pagos están todos soldados juntos. En el momento en que necesitas algo personalizado -- control de contenido basado en roles, generación de cuestionarios impulsada por IA, implementaciones multi-tenant con marca blanca -- estás peleando contra la plataforma en lugar de construir características.
Con Supabase manejando tu base de datos, autenticación, almacenamiento y suscripciones en tiempo real, y Next.js manejando tu capa de renderizado y API, obtienes un sistema que es genuinamente tuyo. Sin bloqueo de proveedor en el lado del contenido. Control total sobre la experiencia del alumno. Y una base de datos Postgres que puedes consultar directamente sin una ida y vuelta HTTP desde componentes del servidor.
Vamos a construirlo.
Tabla de Contenidos
- Por qué elegir una arquitectura sin cabecera para un LMS
- Descripción general de la arquitectura
- Diseño del esquema de la base de datos
- Configurar la autenticación de Supabase con acceso basado en roles
- Seguridad a nivel de fila para contenido multi-tenant
- Construir el frontend con Next.js App Router
- Renderizado de contenido del curso y seguimiento del progreso
- Características en tiempo real: discusiones y notificaciones
- Almacenamiento de vídeos y archivos con Supabase Storage
- Implementación y consideraciones de rendimiento
- Preguntas frecuentes

Por qué elegir una arquitectura sin cabecera para un LMS
Las plataformas LMS tradicionales no fueron construidas para la web moderna. Fueron construidas para departamentos de TI que querían marcar una casilla de cumplimiento. La experiencia del usuario refleja eso.
Un LMS sin cabecera separa tu gestión de contenido y lógica de negocio de tu capa de presentación. Esto significa:
- Tu frontend puede ser cualquier cosa. Una aplicación web Next.js, una aplicación móvil React Native, un bot de Slack que entrega micro-lecciones. La misma API las sirve a todas.
- Tu modelo de contenido es tuyo. Tú defines qué es un "curso". Quizás sea una serie de lecciones en markdown. Quizás sea una colección de módulos de vídeo con ejercicios interactivos integrados. No estás limitado por lo que Moodle cree que debería ser un curso.
- Controlas los datos. El progreso del alumno, los resultados de las evaluaciones, las métricas de engagement -- todo vive en tu base de datos Postgres. Sin exportar CSVs desde un panel de control de un proveedor.
El compromiso es obvio: tienes que construir más por ti mismo. Pero con Supabase manejando autenticación, almacenamiento, tiempo real y la capa de base de datos, la cantidad de código personalizado que realmente escribes es sorprendentemente pequeña.
Descripción general de la arquitectura
Aquí está la arquitectura de alto nivel para lo que estamos construyendo:
┌─────────────────────────────────────────────┐
│ 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)│
└───────────┘
Las decisiones arquitectónicas clave:
| Decisión | Opción | Fundamento |
|---|---|---|
| Renderizado | Componentes del servidor + ISR | Las páginas del catálogo de cursos son principalmente estáticas; los paneles de control del alumno necesitan datos frescos |
| Autenticación | Supabase Auth con PKCE | Integración RLS integrada, sin servicio de autenticación separado |
| Base de datos | Supabase Postgres | Consultas directas desde componentes del servidor, sin ORM necesario |
| Almacenamiento de archivos | Supabase Storage | URLs firmadas para contenido de vídeo, acceso controlado por RLS |
| Tiempo real | Supabase Realtime | Foros de discusión, actualizaciones de progreso en vivo |
| Pagos | Stripe (vía webhooks) | Las funciones de Supabase Edge manejan el procesamiento de webhooks |
| Formato de contenido | MDX almacenado en BD | Contenido estructurado que se renderiza en componentes React |
Los componentes del servidor en Next.js 15 pueden consultar Supabase directamente usando el cliente del servidor -- sin ruta de API necesaria, sin límite de serialización. Esta es una victoria masiva para un LMS donde estás obteniendo datos del curso, progreso del usuario y estado de inscripción en cada carga de página.
Diseño del esquema de la base de datos
El esquema es la columna vertebral de cualquier LMS. He pasado por suficientes iteraciones para saber que obtener esto bien al principio te ahorra semanas después. Aquí hay un esquema que maneja cursos multi-tenant, módulos estructurados, seguimiento de progreso y evaluaciones.
-- Organizaciones (para multi-tenant / marca blanca)
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()
);
-- Perfiles de usuario extendiendo la autenticación de 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()
);
-- Cursos
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)
);
-- Módulos (secciones dentro de un curso)
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()
);
-- Lecciones (unidades de contenido individual)
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()
);
-- Inscripciones
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)
);
-- Seguimiento del progreso
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)
);
-- Intentos de cuestionario
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()
);
Algunas notas sobre el diseño:
- La columna
contenten las lecciones es JSONB. Esto es intencional. Una lección de vídeo almacena{"video_url": "...", "transcript": "..."}. Una lección de texto almacena{"mdx": "..."}. Un cuestionario almacena{"questions": [...]}. Esto te da datos estructurados sin necesidad de una tabla separada para cada tipo de contenido. sort_orderen módulos y lecciones. La reordenación por arrastrar y soltar es imprescindible para cualquier constructor de cursos. Los órdenes de clasificación de enteros hacen que esto sea trivial.- El JSONB de
metadataen cursos. Esta es tu puerta de escape. Campos SEO, marca personalizada, banderas de características -- méte todo aquí sin migraciones de esquema.

Configurar la autenticación de Supabase con acceso basado en roles
Supabase Auth te da correo electrónico/contraseña, OAuth y enlaces mágicos de fábrica. Para un LMS, típicamente necesitas tres roles: admin, instructor y alumno. El truco es sincronizar los auth.users de Supabase con tu tabla profiles.
Primero, configura un disparador de base de datos que cree un perfil cada vez que un nuevo usuario se registra:
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();
Ahora, en el lado de Next.js, configura el cliente SSR de Supabase. Este es el patrón de 2026 usando @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 {
// Llamado desde un componente del servidor -- ignorar
}
},
},
}
)
}
// 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!
)
}
El cliente del servidor lee tokens de autenticación de las cookies automáticamente. En un componente del servidor, puedes hacer esto:
// 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>Bienvenido de vuelta, {profile?.full_name}</h1>
{/* Renderizar cursos inscritos */}
</div>
)
}
Sin ruta de API. Sin llamada fetch. Consulta directa a la base de datos desde un componente del servidor. Esta es la ventaja arquitectónica de Supabase + Next.js App Router.
Seguridad a nivel de fila para contenido multi-tenant
RLS es lo que hace que esta arquitectura sea realmente segura. Sin ella, tu clave anon daría a cualquiera acceso a todo. Con RLS, la base de datos misma aplica quién puede ver y modificar qué.
Aquí hay políticas para las tablas principales:
-- Cursos: cualquiera puede leer cursos publicados, los instructores pueden gestionar los suyos
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Los cursos publicados son visibles para todos" ON courses
FOR SELECT USING (status = 'published');
CREATE POLICY "Los instructores gestionan sus cursos" ON courses
FOR ALL USING (
auth.uid() = instructor_id
);
CREATE POLICY "Los administradores gestionan los cursos de la organización" ON courses
FOR ALL USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'admin'
AND organization_id = courses.organization_id
)
);
-- Lecciones: los usuarios inscritos pueden leer, las vistas previas gratuitas visibles para todos
ALTER TABLE lessons ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Las lecciones de vista previa gratuita son visibles para todos" ON lessons
FOR SELECT USING (is_free_preview = true);
CREATE POLICY "Los usuarios inscritos pueden leer lecciones" 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
)
);
-- Progreso: los usuarios solo pueden ver y actualizar su propio progreso
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Los usuarios gestionan su propio progreso" ON lesson_progress
FOR ALL USING (auth.uid() = user_id);
-- Inscripciones: los usuarios ven la suya, los instructores ven las inscripciones de sus cursos
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Los usuarios ven sus propias inscripciones" ON enrollments
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Los instructores ven las inscripciones del curso" ON enrollments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM courses
WHERE courses.id = enrollments.course_id
AND courses.instructor_id = auth.uid()
)
);
Una cosa que confunde a la gente: las políticas RLS son aditivas para SELECT. Si cualquier política pasa, la fila es visible. Para INSERT, UPDATE y DELETE, todas las políticas aplicables deben pasar. Ten esto en mente al depurar problemas de acceso.
Construir el frontend con Next.js App Router
Aquí está la estructura de ruta que he encontrado que funciona bien para un LMS:
app/
├── (marketing)/
│ ├── page.tsx # Página de destino
│ ├── courses/
│ │ ├── page.tsx # Catálogo de cursos
│ │ └── [slug]/
│ │ └── page.tsx # Detalle del curso / página de ventas
├── (app)/
│ ├── layout.tsx # Diseño autenticado con barra lateral
│ ├── dashboard/
│ │ └── page.tsx # Panel de control del alumno
│ ├── learn/
│ │ └── [courseSlug]/
│ │ └── [lessonId]/
│ │ └── page.tsx # Visor de lecciones
│ ├── instructor/
│ │ ├── courses/
│ │ │ ├── page.tsx # Lista de cursos del instructor
│ │ │ ├── new/
│ │ │ │ └── page.tsx # Crear curso
│ │ │ └── [id]/
│ │ │ └── edit/
│ │ │ └── page.tsx # Editor de cursos
├── api/
│ └── webhooks/
│ └── stripe/
│ └── route.ts # Manejador de webhook de Stripe
Los grupos de ruta (marketing) y (app) te permiten usar diferentes diseños sin afectar la estructura de URL. Las páginas de marketing obtienen un encabezado y pie de página mínimos. La sección de la aplicación obtiene una barra lateral completa con navegación del curso.
Aquí hay una acción del servidor para inscribirse en un curso gratuito:
// 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: 'Debes estar conectado para inscribirte' }
}
// Verificar si el curso es gratuito
const { data: course } = await supabase
.from('courses')
.select('price_cents')
.eq('id', courseId)
.single()
if (course?.price_cents && course.price_cents > 0) {
return { error: 'Este curso requiere pago' }
}
const { error } = await supabase
.from('enrollments')
.insert({ user_id: user.id, course_id: courseId })
if (error) {
if (error.code === '23505') {
return { error: 'Ya estás inscrito en este curso' }
}
return { error: error.message }
}
revalidatePath('/dashboard')
return { success: true }
}
Las acciones del servidor en Next.js 15 son la forma más limpia de manejar mutaciones. Sin rutas de API, sin llamadas fetch manuales. El formulario simplemente llama a la función.
Renderizado de contenido del curso y seguimiento del progreso
Para el visor de lecciones, necesitas dos cosas sucediendo simultáneamente: renderizar el contenido y rastrear el progreso. Aquí está mi enfoque:
// 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')
// Obtener todas las lecciones en este curso para navegación
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')
// Obtener progreso actual
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">
{/* Barra lateral del curso con lista de lecciones */}
</aside>
<main className="flex-1 overflow-y-auto">
<LessonViewer
lesson={lesson}
progress={progress}
/>
<ProgressTracker
lessonId={lessonId}
initialProgress={progress?.progress_pct ?? 0}
/>
</main>
</div>
)
}
El ProgressTracker es un componente del cliente que actualiza el progreso mientras el usuario se desplaza por el contenido de texto o ve vídeo. Utiliza el cliente del navegador Supabase para escribir el progreso en tiempo real:
// 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 // Este componente no tiene UI
}
El rebote de las actualizaciones de progreso es crítico. No quieres golpear la base de datos en cada evento de desplazamiento.
Características en tiempo real: discusiones y notificaciones
Supabase Realtime convierte tu LMS de un visor de contenido estático en algo que se siente vivo. Las dos características que siempre construyo primero: discusiones de lecciones y notificaciones de progreso para instructores.
// 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(() => {
// Obtener mensajes existentes
supabase
.from('discussions')
.select('*, profile:profiles(full_name, avatar_url)')
.eq('lesson_id', lessonId)
.order('created_at')
.then(({ data }) => setMessages(data ?? []))
// Suscribirse a nuevos mensajes
const channel = supabase
.channel(`lesson-${lessonId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'discussions',
filter: `lesson_id=eq.${lessonId}`,
},
async (payload) => {
// Obtener el mensaje completo con perfil
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>
{/* Renderizar mensajes y formulario de entrada */}
</div>
)
}
Almacenamiento de vídeos y archivos con Supabase Storage
Para contenido de vídeo, Supabase Storage con URLs firmadas es el camino a seguir. Crea un depósito privado para vídeos de cursos y usa políticas similares a RLS en la capa de almacenamiento:
-- Política de depósito de almacenamiento: solo los usuarios inscritos pueden acceder a los vídeos del curso
CREATE POLICY "Los usuarios inscritos pueden ver los vídeos del curso"
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 || '/%'
)
);
En el lado del servidor, genera URLs firmadas de corta duración:
const { data } = await supabase.storage
.from('course-videos')
.createSignedUrl(`${courseId}/${lesson.video_filename}`, 3600) // 1 hora
Para producción, querrás poner una CDN delante de esto. La CDN integrada de Supabase maneja la mayoría de casos, pero para entrega de vídeo de alto tráfico, considera transcodificación con un servicio como Mux o Cloudflare Stream y almacena solo los IDs de reproducción en tu base de datos.
Implementación y consideraciones de rendimiento
El plan gratuito de Supabase te da 500MB de base de datos, 1GB de almacenamiento y 50,000 usuarios mensuales activos. Eso es suficiente para lanzar. El plan Pro a $25/mes te da 8GB de base de datos, 100GB de almacenamiento y sin límite de MAU -- lo que maneja un LMS serio con miles de alumnos.
| Plan Supabase | Almacenamiento BD | Almacenamiento de archivos | Límite MAU | Precio |
|---|---|---|---|---|
| Gratuito | 500MB | 1GB | 50,000 | $0 |
| Pro | 8GB | 100GB | Ilimitado | $25/mes |
| Equipo | 8GB | 100GB | Ilimitado | $599/mes |
| Empresa | Personalizado | Personalizado | Ilimitado | Personalizado |
Para la implementación de Next.js, Vercel es la opción obvia -- obtienes ISR automático, middleware de borde y optimización de imágenes. Pero si eres sensible al costo, puedes implementar en Coolify o Railway por significativamente menos.
Consejos de rendimiento de la producción:
- Usa ISR para páginas del catálogo de cursos.
revalidate: 3600significa que tu catálogo se reconstruye como máximo una vez por hora. Lo suficientemente rápido para la mayoría de casos de uso. - Usa componentes del servidor para el visor de lecciones. La carga inicial de la página se SSR'd con todo el contenido. Sin spinner de carga.
- Añade índices de base de datos. Como mínimo:
enrollments(user_id),lesson_progress(user_id, lesson_id),lessons(module_id, sort_order),courses(organization_id, slug). - Agrupación de conexiones. Usa la cadena de conexión integrada de agrupación de Supabase (la de PgBouncer) para consultas del lado del servidor. La conexión directa para migraciones.
Si estás construyendo algo como esto y quieres ayuda con la arquitectura o implementación, nuestro equipo hace desarrollo de Next.js y construcciones de CMS sin cabecera regularmente. Hemos enviado varias aplicaciones respaldadas por Supabase y sabemos dónde están los bordes afilados.
Preguntas frecuentes
¿Puede Supabase manejar la escala de un LMS de producción? Sí. Supabase se ejecuta en Postgres administrado, que maneja millones de filas sin romperse. El plan Pro incluye agrupación de conexiones vía PgBouncer, y puedes escalar el cómputo de forma independiente. Para referencia, Supabase reporta clientes ejecutando 100k+ conexiones concurrentes en su nivel Enterprise. Para la mayoría de casos de uso de LMS -- incluso con decenas de miles de alumnos activos -- el plan Pro a $25/mes es más que suficiente.
¿Cómo manejas el alojamiento de vídeos en un LMS basado en Supabase? Supabase Storage funciona para almacenar y servir archivos de vídeo con URLs firmadas, pero no es una plataforma de vídeo. Para vídeo LMS de producción, recomiendo usar un servicio de vídeo dedicado como Mux ($0.007/min para codificación, $0.00015/seg para entrega) o Cloudflare Stream ($1/1000 min almacenados, $5/1000 min entregados). Almacena los IDs de reproducción en tu base de datos Supabase y usa sus SDK de reproductor en el frontend. Esto te da transmisión adaptativa, análisis y DRM sin construir nada de eso tú mismo.
¿Es Next.js App Router lo suficientemente estable para un LMS de producción en 2026?
Absolutamente. El App Router ha sido estable desde Next.js 14, y Next.js 15 solucionó los bordes restantes con acciones del servidor y almacenamiento en caché. Los componentes del servidor son una ventaja arquitectónica genuina para aplicaciones con mucho uso de datos como un LMS. La API after() en Next.js 15 es particularmente útil para disparar eventos de análisis y actualizaciones de progreso sin bloquear la respuesta.
¿Cómo implementas certificados de finalización del curso?
Rastrear la finalización a nivel de lección con lesson_progress, luego calcular la finalización del curso como un porcentaje de lecciones completadas en todos los módulos. Cuando un usuario alcanza el 100%, dispara una función Supabase Edge (o una acción del servidor Next.js) que genera un certificado PDF usando una biblioteca como @react-pdf/renderer y lo almacena en Supabase Storage. Añade una fila a una tabla certificates con la ruta de almacenamiento y un código de verificación único.
¿Qué pasa con la compatibilidad con SCORM?
SCORM es un estándar heredado que la mayoría de plataformas LMS modernas están abandonando. Dicho esto, si necesitas soporte SCORM -- típicamente para capacitación de cumplimiento corporativo -- puedes analizar paquetes SCORM y almacenar el contenido extraído en tu base de datos Supabase. Las bibliotecas como pipwerks-scorm-api-wrapper manejan la API de tiempo de ejecución. Es posible pero añade una complejidad significativa. Lo evitaría a menos que un cliente específicamente lo requiera.
¿Cómo manejas los pagos para cursos pagos?
Stripe Checkout es el camino más simple. Crea una sesión de Stripe Checkout desde una acción del servidor, redirige al usuario a Stripe y maneja el webhook checkout.session.completed en una ruta de API Next.js o función Supabase Edge. El webhook crea el registro de inscripción. Esto mantiene tu lógica de pagos simple y compatible con PCI sin manejar datos de tarjeta de crédito tú mismo.
¿Puede esta arquitectura soportar también una aplicación móvil?
Esa es una de las mayores ventajas de elegir una arquitectura sin cabecera. Tu base de datos Supabase y autenticación funcionan de forma idéntica desde una aplicación React Native vía @supabase/supabase-js. Las mismas políticas RLS protegen los datos. Simplemente construyes una capa de UI diferente. Hemos visto equipos enviar web y móvil desde el mismo backend Supabase sin cambios de API.
¿Cómo añades características de IA como generación de cuestionarios o resumen de contenido? Almacena tu contenido de lección como JSONB estructurado (no blobs de HTML). Luego crea una función Supabase Edge que envía contenido de lección a un LLM (OpenAI, Anthropic o un modelo auto-hospedado) para generar preguntas de cuestionario, resúmenes o guías de estudio. Almacena el contenido generado de vuelta en campos JSONB. El modelo de datos estructurados que diseñamos anteriormente hace que esto sea directo -- los sistemas de IA pueden consumir campos específicos sin analizar marcado. Si estás interesado en este tipo de trabajo, ponte en contacto con nuestro equipo -- hemos estado construyendo integraciones de IA en plataformas de contenido todo el año.