Supabase como Backend Completo para Next.js

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
O problema que o Supabase resolve (e o que ele não resolve)
Montar um backend para uma aplicação Next.js exige decisões sobre autenticação, banco de dados, storage de arquivos e, dependendo do caso, comunicação em tempo real. A abordagem clássica envolve configurar um PostgreSQL, um serviço de auth (Auth0, Clerk, NextAuth), um S3 para arquivos e um servidor WebSocket separado. São quatro serviços, quatro faturas, quatro pontos de falha.
O Supabase empacota tudo isso em cima de um PostgreSQL real: auth com JWT, storage com políticas de acesso, realtime via WebSockets e uma API REST gerada automaticamente pelo PostgREST. A proposta é eliminar boilerplate de infraestrutura sem abrir mão do controle que um banco relacional oferece.
Onde o Supabase não encaixa: se você precisa de lógica de negócio complexa no backend (filas, workers, orquestração de serviços), ele não substitui um servidor dedicado. Nesses casos, o Supabase funciona como camada de dados e auth, e a lógica fica em API Routes do Next.js ou em um serviço separado.
Instalação e configuração inicial
Crie um projeto no dashboard do Supabase e copie a SUPABASE_URL e a SUPABASE_ANON_KEY. A anon key é pública por design: a segurança vem do Row Level Security, não do sigilo dessa chave.
npm install @supabase/supabase-js @supabase/ssrCrie o arquivo de variáveis de ambiente:
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://seuprojetoid.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...O prefixo NEXT_PUBLIC_ expõe essas variáveis no client. Isso é intencional: a anon key só permite operações que as políticas RLS autorizam.
Criando os clientes: server e browser
O @supabase/ssr oferece helpers para criar clientes que gerenciam cookies corretamente no App Router. A separação entre client de servidor e client de browser é obrigatória porque o Next.js executa código nos dois ambientes.
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createSupabaseServer() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
// getAll/setAll é a API recomendada pelo @supabase/ssr v0.5+
// evita problemas de sincronização com cookies individuais
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// setAll falha em Server Components (read-only)
// funciona em Server Actions e Route Handlers
}
},
},
}
);
}// src/lib/supabase/browser.ts
import { createBrowserClient } from "@supabase/ssr";
export function createSupabaseBrowser() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}O middleware renova tokens expirados antes que a request chegue ao Server Component:
// src/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
// Propaga cookies tanto na request quanto na response
// sem isso, Server Components recebem sessão stale
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
// getUser() valida o JWT no servidor do Supabase
// getSession() confia no token local e pode ser spoofado
const { data: { user } } = await supabase.auth.getUser();
if (!user && request.nextUrl.pathname.startsWith("/dashboard")) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)"],
};Autenticação com Server Actions
Em vez de criar Route Handlers para login e signup, Server Actions simplificam o fluxo porque executam no servidor e podem setar cookies diretamente.
// src/app/login/actions.ts
"use server";
import { redirect } from "next/navigation";
import { createSupabaseServer } from "@/lib/supabase/server";
export async function loginWithEmail(formData: FormData) {
const supabase = await createSupabaseServer();
const { error } = await supabase.auth.signInWithPassword({
email: formData.get("email") as string,
password: formData.get("password") as string,
});
if (error) {
// Retornar o erro para o client tratar na UI
return { error: error.message };
}
redirect("/dashboard");
}
export async function signUpWithEmail(formData: FormData) {
const supabase = await createSupabaseServer();
const { error } = await supabase.auth.signUp({
email: formData.get("email") as string,
password: formData.get("password") as string,
options: {
// emailRedirectTo precisa bater com o Site URL configurado no Supabase
emailRedirectTo: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/auth/v1/callback`,
},
});
if (error) {
return { error: error.message };
}
return { success: "Verifique seu e-mail para confirmar o cadastro." };
}Schema e Row Level Security
O Supabase sem RLS é um banco aberto para qualquer pessoa com a anon key. Habilitar RLS e escrever políticas é o passo que transforma a anon key de vulnerabilidade em design intencional.
-- Tabela de projetos vinculados ao usuário autenticado
CREATE TABLE projects (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL CHECK (char_length(name) BETWEEN 1 AND 200),
description TEXT,
created_at TIMESTAMPTZ DEFAULT now() NOT NULL
);
-- RLS precisa estar habilitado, senão as policies não fazem nada
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Cada usuário só lê seus próprios projetos
-- auth.uid() extrai o user_id do JWT que o PostgREST recebe
CREATE POLICY "users_read_own_projects" ON projects
FOR SELECT USING (auth.uid() = user_id);
-- Inserção força o user_id a ser o do token, impedindo impersonação
CREATE POLICY "users_insert_own_projects" ON projects
FOR INSERT WITH CHECK (auth.uid() = user_id);
-- Update e delete seguem a mesma lógica
CREATE POLICY "users_update_own_projects" ON projects
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "users_delete_own_projects" ON projects
FOR DELETE USING (auth.uid() = user_id);Se você precisa de migrations versionadas e seguras, o Supabase CLI oferece supabase db diff e supabase migration new para gerar arquivos SQL versionados. Para cenários mais complexos de evolução de schema, vale conferir as estratégias de zero-downtime migrations.
Queries tipadas com geração automática
O Supabase CLI gera tipos TypeScript a partir do schema do banco. Sem isso, toda query retorna any e você perde a principal vantagem de usar TypeScript.
npx supabase gen types typescript --project-id seuprojetoid > src/lib/supabase/database.types.tsUse os tipos gerados ao criar os clientes:
// src/lib/supabase/server.ts (versão tipada)
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "./database.types";
export async function createSupabaseServer() {
const cookieStore = await cookies();
return createServerClient<Database>(
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 {}
},
},
}
);
}Com os tipos no lugar, o autocomplete funciona em toda a cadeia: supabase.from("projects").select("id, name") retorna o tipo correto sem cast manual.
CRUD em Server Components e Server Actions
Buscar dados em um Server Component e mutar com Server Actions é o padrão que mantém a lógica no servidor e o client leve:
// src/app/dashboard/page.tsx
import { createSupabaseServer } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const supabase = await createSupabaseServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) redirect("/login");
const { data: projects, error } = await supabase
.from("projects")
.select("id, name, description, created_at")
.order("created_at", { ascending: false });
if (error) {
// Em produção, log estruturado + fallback UI
console.error("Falha ao buscar projetos:", error.message);
return <p>Erro ao carregar projetos.</p>;
}
return (
<section>
<h1>Seus projetos</h1>
<ul>
{projects.map((project) => (
<li key={project.id}>
<strong>{project.name}</strong>
<p>{project.description}</p>
</li>
))}
</ul>
</section>
);
}A query acima não precisa de cláusula WHERE user_id = ... porque a política RLS já filtra. O PostgREST injeta o auth.uid() automaticamente.
O que NÃO fazer
Erro 1: Usar getSession() no servidor para validar autenticação
// ERRADO: getSession() confia no token local sem validar no servidor
const { data: { session } } = await supabase.auth.getSession();
if (session) {
// Um atacante pode forjar o JWT no cookie
// e getSession() vai aceitar sem reclamar
}// CORRETO: getUser() faz uma request ao servidor Supabase
// e valida o JWT contra a chave secreta do projeto
const { data: { user } } = await supabase.auth.getUser();
if (user) {
// Token validado no servidor, seguro para tomar decisões de autorização
}A documentação oficial do Supabase marca getSession() como inseguro para server-side desde a versão 2.x do @supabase/ssr.
Erro 2: Desabilitar RLS "para testar" e esquecer de reabilitar
-- ERRADO: tabela aberta para qualquer request com a anon key
ALTER TABLE projects DISABLE ROW LEVEL SECURITY;
-- "Depois eu habilito" — famosas últimas palavras-- CORRETO: habilite RLS antes de criar qualquer dado
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Crie policies específicas por operação (SELECT, INSERT, UPDATE, DELETE)
-- Uma policy genérica FOR ALL é mais difícil de auditarErro 3: Criar um único client Supabase e reutilizar entre requests
// ERRADO: singleton compartilhado entre requests no servidor
// cada request tem cookies diferentes, então o user é diferente
const supabase = createServerClient(/* ... */);
export function getSharedClient() {
return supabase; // Vai retornar o user da primeira request
}Crie um client novo por request. O overhead de instanciação é desprezível comparado ao risco de vazar sessão entre usuários.
Supabase vs. alternativas para Next.js
| Critério | Supabase | Firebase | Prisma + Auth separado |
|---|---|---|---|
| Banco de dados | PostgreSQL real, SQL completo, busca full-text com tsvector | Firestore (NoSQL, sem joins) | Qualquer banco relacional |
| Auth integrado | Sim, com JWT e RLS | Sim, Firebase Auth | Não, precisa de Clerk/NextAuth/Auth0 |
| Storage | Integrado com policies | Firebase Storage | S3 ou similar, separado |
| Realtime | WebSocket nativo via Postgres changes | Firestore listeners | Precisa de setup manual com WebSockets |
| Vendor lock-in | Baixo (PostgreSQL padrão, self-host possível) | Alto (APIs proprietárias) | Nenhum |
| Lógica no backend | Edge Functions (Deno) ou API Routes do Next.js | Cloud Functions | Controle total |
| Custo (tier gratuito) | 500MB banco, 1GB storage, 50k auth users | 1GB Firestore, 5GB storage | Depende do provider |
Se o projeto precisa de queries SQL complexas, joins, constraints e migrations versionadas, Supabase ou Prisma são as opções viáveis. Firebase força modelagem NoSQL que complica relatórios e queries ad hoc.
Storage com políticas de acesso
O storage do Supabase segue a mesma lógica de RLS: buckets com policies SQL.
-- Criar bucket para avatares dos usuários
INSERT INTO storage.buckets (id, name, public)
VALUES ('avatars', 'avatars', false);
-- Cada usuário só acessa seus próprios arquivos
-- O path segue o padrão: user_id/filename
CREATE POLICY "users_manage_own_avatars" ON storage.objects
FOR ALL USING (
bucket_id = 'avatars'
AND auth.uid()::text = (storage.foldername(name))[1]
);Upload a partir de um Server Action:
// src/app/profile/actions.ts
"use server";
import { createSupabaseServer } from "@/lib/supabase/server";
export async function uploadAvatar(formData: FormData) {
const supabase = await createSupabaseServer();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return { error: "Não autenticado" };
const file = formData.get("avatar") as File;
if (!file || file.size === 0) return { error: "Nenhum arquivo enviado" };
// Prefixar com user.id garante que a policy de storage autorize o upload
const filePath = `${user.id}/${Date.now()}-${file.name}`;
const { error } = await supabase.storage
.from("avatars")
.upload(filePath, file, {
upsert: true, // Sobrescreve se já existir
contentType: file.type,
});
if (error) return { error: error.message };
return { path: filePath };
}Checklist de segurança antes do deploy
- RLS habilitado em todas as tabelas que a anon key acessa.
- Policies testadas com
supabase testou queries manuais usando roles diferentes. getUser()(nãogetSession()) em todo código server-side que toma decisão de autorização.- Middleware do Next.js renovando tokens antes dos Server Components.
SUPABASE_SERVICE_ROLE_KEYnunca exposta no client (sem prefixoNEXT_PUBLIC_).- Buckets de storage com policies explícitas, nunca públicos por padrão.
- Rate limiting configurado no API Gateway ou nas Edge Functions para endpoints sensíveis.
FAQ
O Supabase é gratuito para projetos em produção? O tier gratuito suporta até 500MB de banco, 1GB de storage e 50 mil usuários autenticados. Para a maioria dos MVPs e projetos com até alguns milhares de usuários ativos, isso basta. Acima disso, o plano Pro começa em 25 dólares/mês. Se o custo preocupa, o Supabase é self-hostável com Docker, mas você assume a operação do PostgreSQL, GoTrue e Storage.
Preciso do Prisma se já uso o Supabase? Depende de onde a lógica de dados vive. Se você faz queries exclusivamente pelo client do Supabase (via PostgREST), o Prisma é redundante. Se parte da lógica roda em API Routes do Next.js e você prefere um ORM com migrations tipadas, Prisma e Supabase coexistem: Prisma acessa o banco diretamente via connection string, e o Supabase cuida de auth e realtime.
Como funciona o realtime do Supabase?
O Supabase escuta mudanças no PostgreSQL via replication slots e publica via WebSocket. Você se inscreve em INSERT, UPDATE ou DELETE de tabelas específicas. As policies de RLS se aplicam ao realtime: um usuário só recebe eventos de linhas que ele tem permissão de ler. Para cenários mais complexos de notificações, a implementação manual com WebSockets oferece mais controle.
Posso usar o Supabase com Pages Router em vez de App Router?
Sim. O @supabase/ssr funciona com ambos. A diferença é que no Pages Router você usa getServerSideProps para criar o client de servidor, e no App Router usa Server Components e Server Actions. O middleware funciona igual nos dois.
O RLS impacta performance?
As policies RLS são executadas como predicados SQL adicionais em cada query. Para tabelas com índice no user_id (que é o caso mais comum), o impacto é negligível: o planner do PostgreSQL usa o índice normalmente. O problema aparece quando a policy faz subquery em tabelas sem índice ou usa funções custosas. Mantenha as policies simples e indexe as colunas referenciadas.
Posição editorial
O Supabase é a escolha mais produtiva para projetos Next.js que precisam de auth, banco relacional e storage sem montar infraestrutura própria. A combinação de PostgreSQL real com RLS resolve o problema de segurança de dados de um jeito que o Firebase nunca conseguiu: com SQL, constraints e políticas auditáveis.
O risco está em tratar o Supabase como caixa preta. Se você não entende RLS, não entende onde está a segurança da sua aplicação. Se não gera tipos com o CLI, perde type safety. Se não separa os clientes de server e browser, vaza sessão entre usuários. A ferramenta é boa, mas exige que você saiba PostgreSQL. Se o time não tem essa competência, o Supabase vai parecer mágico até o primeiro incidente de dados expostos por uma tabela sem policy.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Guias de integração relacionados
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


