Construa um LMS Headless com Supabase e Next.js em 2026
Construí três plataformas LMS nos últimos dois anos. Duas delas usaram soluções prontas que eventualmente removemos. A terceira -- a que realmente funcionou -- foi uma arquitetura headless construída em Next.js e Supabase. É essa que vou guiá-lo na construção.
O argumento para um LMS headless é simples: plataformas comerciais como Teachable, Thinkific ou até mesmo Moodle oferecem uma experiência monolítica onde o modelo de conteúdo, o frontend, o sistema de autenticação e o fluxo de pagamento estão todos soldados juntos. No momento em que você precisa de algo customizado -- controle de conteúdo baseado em função, geração de quiz alimentada por IA, implantações white-label multi-tenant -- você está lutando contra a plataforma em vez de construir recursos.
Com Supabase lidando com seu banco de dados, autenticação, armazenamento e subscrições em tempo real, e Next.js lidando com sua renderização e camada de API, você obtém um sistema que é genuinamente seu. Sem vendor lock-in no lado do conteúdo. Controle total sobre a experiência do aprendiz. E um banco de dados Postgres que você pode consultar diretamente sem uma volta HTTP de componentes de servidor.
Vamos construir.
Índice
- Por que usar Headless para um LMS
- Visão Geral da Arquitetura
- Design do Schema do Banco de Dados
- Configurando Supabase Auth com Acesso Baseado em Função
- Row Level Security para Conteúdo Multi-Tenant
- Construindo o Frontend do Next.js App Router
- Renderização de Conteúdo do Curso e Rastreamento de Progresso
- Recursos em Tempo Real: Discussões e Notificações
- Armazenamento de Vídeo e Arquivo com Supabase Storage
- Deployment e Considerações de Performance
- FAQ

Por que usar Headless para um LMS
Plataformas LMS tradicionais não foram construídas para a web moderna. Foram construídas para departamentos de TI que queriam marcar uma caixa de conformidade. A experiência do usuário reflete isso.
Um LMS headless separa seu gerenciamento de conteúdo e lógica de negócios de sua camada de apresentação. Isso significa:
- Seu frontend pode ser qualquer coisa. Um aplicativo web Next.js, um aplicativo React Native mobile, um bot Slack que entrega micro-lições. A mesma API os atende a todos.
- Seu modelo de conteúdo é seu. Você define o que é um "curso". Talvez seja uma série de lições em markdown. Talvez seja uma coleção de módulos de vídeo com exercícios interativos incorporados. Você não está restringido pelo que Moodle acha que um curso deveria ser.
- Você controla os dados. Progresso do aprendiz, resultados de avaliações, métricas de engajamento -- tudo reside em seu banco de dados Postgres. Sem exportar CSVs de um painel de vendedor.
A compensação é óbvia: você tem que construir mais você mesmo. Mas com Supabase lidando com autenticação, armazenamento, tempo real e a camada de banco de dados, a quantidade de código customizado que você realmente escreve é surpreendentemente pequena.
Visão Geral da Arquitetura
Aqui está a arquitetura de alto nível para o que estamos construindo:
┌─────────────────────────────────────────────┐
│ 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)│
└───────────┘
As principais decisões arquiteturais:
| Decisão | Escolha | Justificativa |
|---|---|---|
| Renderização | Componentes de Servidor + ISR | Páginas de catálogo de cursos são principalmente estáticas; painéis de aprendiz precisam de dados frescos |
| Auth | Supabase Auth com PKCE | Integração RLS integrada, sem serviço de auth separado |
| Banco de Dados | Supabase Postgres | Consultas diretas de componentes de servidor, sem ORM necessário |
| Armazenamento de Arquivo | Supabase Storage | URLs assinadas para conteúdo de vídeo, acesso gated por RLS |
| Tempo Real | Supabase Realtime | Fóruns de discussão, atualizações de progresso ao vivo |
| Pagamentos | Stripe (via webhooks) | Supabase Edge Functions lidam com processamento de webhook |
| Formato de Conteúdo | MDX armazenado em DB | Conteúdo estruturado que renderiza em componentes React |
Componentes de servidor no Next.js 15 podem consultar Supabase diretamente usando o cliente de servidor -- nenhuma rota de API necessária, sem limite de serialização. Esta é uma grande vitória para um LMS onde você está buscando dados de curso, progresso do usuário e status de inscrição em cada carregamento de página.
Design do Schema do Banco de Dados
O schema é a espinha dorsal de qualquer LMS. Passei por iterações suficientes para saber que acertar isso desde o início economiza semanas depois. Aqui está um schema que lida com cursos multi-tenant, módulos estruturados, rastreamento de progresso e avaliações.
-- Organizations (para 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 estendendo 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 (seções dentro de um 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()
);
-- Lessons (unidades de conteúdo 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()
);
-- 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)
);
-- Rastreamento de progresso
CREATE TABLE lesson_progress (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
status TEXT DEFAULT 'not_started' CHECK (status IN ('not_started', 'in_progress', 'completed')),
progress_pct SMALLINT DEFAULT 0 CHECK (progress_pct BETWEEN 0 AND 100),
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, lesson_id)
);
-- Quiz attempts
CREATE TABLE quiz_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES profiles(id) ON DELETE CASCADE,
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE,
answers JSONB NOT NULL,
score SMALLINT,
passed BOOLEAN,
attempted_at TIMESTAMPTZ DEFAULT NOW()
);
Algumas notas de design:
- A coluna
contentem lessons é JSONB. Isso é intencional. Uma lição de vídeo armazena{"video_url": "...", "transcript": "..."}. Uma lição de texto armazena{"mdx": "..."}. Um quiz armazena{"questions": [...]}. Isso oferece dados estruturados sem precisar de uma tabela separada para cada tipo de conteúdo. sort_orderem modules e lessons. O reordenamento com arrastar e soltar é imprescindível para qualquer construtor de cursos. Ordens de classificação inteiras tornam isso trivial.- O
metadataJSONB em courses. Este é seu escape hatch. Campos de SEO, branding customizado, feature flags -- jogue tudo aqui sem migrações de schema.

Configurando Supabase Auth com Acesso Baseado em Função
Supabase Auth oferece email/senha, OAuth e magic links imediatamente. Para um LMS, você normalmente precisa de três funções: admin, instructor e learner. O truque é sincronizar o auth.users do Supabase com sua tabela profiles.
Primeiro, configure um trigger de banco de dados que cria um perfil sempre que um novo usuário se inscreve:
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();
Agora, no lado do Next.js, configure o cliente SSR do Supabase. Este é o padrão 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 {
// Chamado de um Componente de 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!
)
}
O cliente de servidor lê tokens de autenticação de cookies automaticamente. Em um componente de servidor, você pode fazer isso:
// 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>Bem-vindo de volta, {profile?.full_name}</h1>
{/* Renderizar cursos inscritos */}
</div>
)
}
Nenhuma rota de API. Nenhuma chamada fetch. Consulta direta de banco de dados de um componente de servidor. Esta é a vantagem arquitetural de Supabase + Next.js App Router.
Row Level Security para Conteúdo Multi-Tenant
RLS é o que torna essa arquitetura realmente segura. Sem ele, sua chave anon daria a qualquer um acesso a tudo. Com RLS, o próprio banco de dados impõe quem pode ver e modificar o quê.
Aqui estão as políticas para as tabelas principais:
-- Courses: qualquer um pode ler cursos publicados, instrutores podem gerenciar os seus
ALTER TABLE courses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Cursos publicados são visíveis para todos" ON courses
FOR SELECT USING (status = 'published');
CREATE POLICY "Instrutores gerenciam seus cursos" ON courses
FOR ALL USING (
auth.uid() = instructor_id
);
CREATE POLICY "Admins gerenciam cursos org" ON courses
FOR ALL USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid()
AND role = 'admin'
AND organization_id = courses.organization_id
)
);
-- Lessons: usuários inscritos podem ler, visualizações gratuitas visíveis para todos
ALTER TABLE lessons ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Lições de visualização gratuita visíveis para todos" ON lessons
FOR SELECT USING (is_free_preview = true);
CREATE POLICY "Usuários inscritos podem ler lições" 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: usuários podem ver e atualizar apenas seu próprio progresso
ALTER TABLE lesson_progress ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Usuários gerenciam seu próprio progresso" ON lesson_progress
FOR ALL USING (auth.uid() = user_id);
-- Enrollments: usuários veem seus próprios, instrutores veem inscrições de seus cursos
ALTER TABLE enrollments ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Usuários veem seus próprios enrollments" ON enrollments
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Instrutores veem enrollments do curso" ON enrollments
FOR SELECT USING (
EXISTS (
SELECT 1 FROM courses
WHERE courses.id = enrollments.course_id
AND courses.instructor_id = auth.uid()
)
);
Uma coisa que confunde as pessoas: as políticas RLS são aditivas para SELECT. Se qualquer política passar, a linha é visível. Para INSERT, UPDATE e DELETE, todas as políticas aplicáveis devem passar. Tenha isso em mente ao depurar problemas de acesso.
Construindo o Frontend do Next.js App Router
Aqui está a estrutura de rota que descobri funcionar bem para um LMS:
app/
├── (marketing)/
│ ├── page.tsx # Página inicial
│ ├── courses/
│ │ ├── page.tsx # Catálogo de cursos
│ │ └── [slug]/
│ │ └── page.tsx # Detalhe do curso / página de vendas
├── (app)/
│ ├── layout.tsx # Layout autenticado com sidebar
│ ├── dashboard/
│ │ └── page.tsx # Painel do aprendiz
│ ├── learn/
│ │ └── [courseSlug]/
│ │ └── [lessonId]/
│ │ └── page.tsx # Visualizador de lição
│ ├── instructor/
│ │ ├── courses/
│ │ │ ├── page.tsx # Lista de cursos do instrutor
│ │ │ ├── new/
│ │ │ │ └── page.tsx # Criar curso
│ │ │ └── [id]/
│ │ │ └── edit/
│ │ │ └── page.tsx # Editor de curso
├── api/
│ └── webhooks/
│ └── stripe/
│ └── route.ts # Manipulador de webhook Stripe
Os grupos de rota (marketing) e (app) permitem que você use layouts diferentes sem afetar a estrutura de URL. Páginas de marketing obtêm um header e footer mínimos. A seção do app obtém uma sidebar completa com navegação de curso.
Aqui está uma server action para inscrever-se em um 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: 'Você deve estar logado para se inscrever' }
}
// Verifique se o curso é 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 requer pagamento' }
}
const { error } = await supabase
.from('enrollments')
.insert({ user_id: user.id, course_id: courseId })
if (error) {
if (error.code === '23505') {
return { error: 'Você já está inscrito neste curso' }
}
return { error: error.message }
}
revalidatePath('/dashboard')
return { success: true }
}
Server actions no Next.js 15 são a maneira mais limpa de lidar com mutações. Sem rotas de API, sem chamadas fetch manuais. O formulário apenas chama a função.
Renderização de Conteúdo do Curso e Rastreamento de Progresso
Para o visualizador de lição, você precisa de duas coisas acontecendo simultaneamente: renderizar o conteúdo e rastrear o progresso. Aqui está minha abordagem:
// 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')
// Obtenha todas as lições neste curso para navegação
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')
// Obtenha o progresso atual
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">
{/* Sidebar do curso com lista de lições */}
</aside>
<main className="flex-1 overflow-y-auto">
<LessonViewer
lesson={lesson}
progress={progress}
/>
<ProgressTracker
lessonId={lessonId}
initialProgress={progress?.progress_pct ?? 0}
/>
</main>
</div>
)
}
O ProgressTracker é um componente cliente que atualiza o progresso conforme o usuário rola pelo conteúdo de texto ou assiste a vídeo. Ele usa o cliente Supabase do navegador para escrever o progresso em tempo 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 não tem UI
}
Debouncing das atualizações de progresso é crítico. Você não quer atingir o banco de dados em cada evento de scroll.
Recursos em Tempo Real: Discussões e Notificações
Supabase Realtime transforma seu LMS de um visualizador de conteúdo estático em algo que parece vivo. Os dois recursos que sempre construo primeiro: discussões de lição e notificações de progresso para instrutores.
// 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(() => {
// Buscar mensagens existentes
supabase
.from('discussions')
.select('*, profile:profiles(full_name, avatar_url)')
.eq('lesson_id', lessonId)
.order('created_at')
.then(({ data }) => setMessages(data ?? []))
// Inscrever-se em novas mensagens
const channel = supabase
.channel(`lesson-${lessonId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'discussions',
filter: `lesson_id=eq.${lessonId}`,
},
async (payload) => {
// Buscar a mensagem completa com 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 mensagens e formulário de entrada */}
</div>
)
}
Armazenamento de Vídeo e Arquivo com Supabase Storage
Para conteúdo de vídeo, Supabase Storage com URLs assinadas é o caminho a seguir. Crie um bucket privado para vídeos do curso e use políticas semelhantes a RLS na camada de armazenamento:
-- Política de bucket de armazenamento: apenas usuários inscritos podem acessar vídeos do curso
CREATE POLICY "Usuários inscritos podem visualizar vídeos do 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 || '/%'
)
);
No lado do servidor, gere URLs assinadas de curta duração:
const { data } = await supabase.storage
.from('course-videos')
.createSignedUrl(`${courseId}/${lesson.video_filename}`, 3600) // 1 hora
Para produção, você vai querer colocar um CDN na frente disso. O CDN integrado do Supabase lida com a maioria dos casos, mas para entrega de vídeo de alto tráfego, considere transcodificar com um serviço como Mux ou Cloudflare Stream e armazenar apenas os IDs de reprodução em seu banco de dados.
Deployment e Considerações de Performance
O nível gratuito do Supabase oferece 500MB de banco de dados, 1GB de armazenamento e 50.000 usuários ativos mensais. Isso é suficiente para lançar. O plano Pro por $25/mês oferece 8GB de banco de dados, 100GB de armazenamento e sem limite de MAU -- que lida com um LMS sério com milhares de aprendizes.
| Plano Supabase | Armazenamento DB | Armazenamento de Arquivo | Limite MAU | Preço |
|---|---|---|---|---|
| Free | 500MB | 1GB | 50.000 | $0 |
| Pro | 8GB | 100GB | Ilimitado | $25/mês |
| Team | 8GB | 100GB | Ilimitado | $599/mês |
| Enterprise | Custom | Custom | Ilimitado | Custom |
Para o deployment do Next.js, Vercel é a escolha óbvia -- você obtém ISR automático, middleware de borda e otimização de imagem. Mas se você é sensível ao custo, pode fazer deploy em Coolify ou Railway por significativamente menos.
Dicas de performance da produção:
- Use ISR para páginas de catálogo de cursos.
revalidate: 3600significa que seu catálogo é reconstruído no máximo uma vez a cada hora. Rápido o suficiente para a maioria dos casos de uso. - Use componentes de servidor para o visualizador de lição. O carregamento inicial da página é SSR'd com todo o conteúdo. Sem spinners de carregamento.
- Adicione índices de banco de dados. No mínimo:
enrollments(user_id),lesson_progress(user_id, lesson_id),lessons(module_id, sort_order),courses(organization_id, slug). - Connection pooling. Use a string de conexão de pooling integrada do Supabase (PgBouncer) para consultas do lado do servidor. A conexão direta para migrações.
Se você está construindo algo assim e quer ajuda com a arquitetura ou implementação, nosso time faz desenvolvimento Next.js e construções de CMS headless regularmente. Enviamos várias aplicações apoiadas por Supabase e sabemos onde estão as arestas vivas.
FAQ
Supabase pode lidar com a escala de um LMS de produção?
Sim. Supabase roda em Postgres gerenciado, que lida com milhões de linhas sem quebrar. O plano Pro inclui pooling de conexão via PgBouncer, e você pode dimensionar compute independentemente. Para referência, Supabase relata clientes executando 100k+ conexões simultâneas em seu nível Enterprise. Para a maioria dos casos de uso do LMS -- mesmo com dezenas de milhares de aprendizes ativos -- o plano Pro por $25/mês é mais que suficiente.
Como você lida com hospedagem de vídeo em um LMS baseado em Supabase?
Supabase Storage funciona para armazenar e servir arquivos de vídeo com URLs assinadas, mas não é uma plataforma de vídeo. Para vídeo LMS de produção, recomendo usar um serviço de vídeo dedicado como Mux ($0,007/min para codificação, $0,00015/seg para entrega) ou Cloudflare Stream ($1/1000 min armazenado, $5/1000 min entregue). Armazene os IDs de reprodução no banco de dados Supabase e use seus SDKs de player no frontend. Isso oferece taxa de bits adaptativa, análises e DRM sem que você construa qualquer um deles.
O Next.js App Router é estável o suficiente para um LMS de produção em 2026?
Absolutamente. O App Router está estável desde Next.js 14, e Next.js 15 resolveu as arestas vivas restantes com server actions e caching. Componentes de servidor são uma vantagem arquitetural genuína para aplicações com muitos dados como um LMS. A API after() no Next.js 15 é particularmente útil para disparar eventos de analytics e atualizações de progresso sem bloquear a resposta.
Como você implementa certificados de conclusão de curso?
Rastreie conclusão no nível de lição com lesson_progress, depois calcule a conclusão do curso como uma porcentagem de lições concluídas em todos os módulos. Quando um usuário atinge 100%, dispare uma Supabase Edge Function (ou uma server action do Next.js) que gera um certificado em PDF usando uma biblioteca como @react-pdf/renderer e o armazena em Supabase Storage. Adicione uma linha a uma tabela certificates com o caminho de armazenamento e um código de verificação único.
E quanto à conformidade SCORM?
SCORM é um padrão legado que a maioria das plataformas LMS modernas está deixando de lado. Dito isso, se você precisar de suporte SCORM -- tipicamente para treinamento de conformidade corporativa -- você pode analisar pacotes SCORM e armazenar o conteúdo extraído em seu banco de dados Supabase. Bibliotecas como pipwerks-scorm-api-wrapper lidam com a API de tempo de execução. É possível mas adiciona complexidade significativa. Eu evitaria a menos que um cliente especificamente o exija.
Como você lida com pagamentos para cursos pagos?
Stripe Checkout é o caminho mais simples. Crie uma sessão Stripe Checkout de uma server action, redirecione o usuário para Stripe e manipule o webhook checkout.session.completed em uma rota de API do Next.js ou Supabase Edge Function. O webhook cria o registro de inscrição. Isso mantém sua lógica de pagamento simples e em conformidade com PCI sem lidar com dados de cartão de crédito você mesmo.
Essa arquitetura pode suportar um aplicativo mobile também?
Essa é uma das maiores vantagens de usar headless. Seu banco de dados Supabase e auth funcionam de forma idêntica de um aplicativo React Native via @supabase/supabase-js. As mesmas políticas RLS protegem os dados. Você apenas constrói uma camada de UI diferente. Vimos times enviarem web e mobile do mesmo backend Supabase sem alterações de API.
Como você adiciona recursos de IA como geração de quiz ou resumo de conteúdo?
Armazene o conteúdo de sua lição como JSONB estruturado (não blobs de HTML). Depois crie uma Supabase Edge Function que envia conteúdo de lição para um LLM (OpenAI, Anthropic ou um modelo auto-hospedado) para gerar questões de quiz, resumos ou guias de estudo. Armazene o conteúdo gerado de volta em campos JSONB. O modelo de dados estruturado que projetamos anteriormente torna isso direto -- sistemas de IA podem consumir campos específicos sem analisar markup. Se você está interessado neste tipo de trabalho, entre em contato com nosso time -- estamos construindo integrações de IA em plataformas de conteúdo o ano todo.