Multi-Tenant Next.js com Supabase RLS: Guia de Produção
Por que Supabase RLS para Multi-Tenancy
Vamos ser honestos: quando se trata de lidar com multi-tenancy em aplicações SaaS, você tem opções. Você pode iniciar um banco de dados separado por tenant, o que parece ser a nirvana organizacional, mas é caro e absolutamente doloroso de gerenciar. Ou, você pode tentar usar esquemas separados, o que é menos trabalhoso operacionalmente, mas ainda não é um passeio no parque quando se trata de migrações. Mas então existe a querida do mundo SaaS—tabelas compartilhadas com filtragem por linha. O Supabase torna essa abordagem muito fácil graças ao seu Row Level Security (RLS) nativo do PostgreSQL.
Por que isso é importante? Simples. Sua filtragem de dados acontece no nível do banco de dados. Se você errar em uma cláusula WHERE em sua rota da API Next.js, você não ficará acordado à noite pensando em vazamentos de dados, porque o banco de dados em si é sua rede de segurança. E realmente, nos dias de hoje, isso não é um luxo—é uma necessidade.
Mas não nos enganemos. RLS adiciona overhead às suas queries, complica a depuração e pode te pegar durante migrações. Então, como as diferentes abordagens de multi-tenancy se comparam?
| Abordagem | Nível de Isolamento | Custo | Complexidade Operacional | Performance de Query |
|---|---|---|---|---|
| Banco de dados por tenant | Completo | Alto ($50-200/tenant/mês) | Muito Alto | Melhor |
| Schema por tenant | Forte | Médio | Alto (migrações) | Bom |
| Tabelas compartilhadas + RLS | Por linha | Baixo | Médio | Bom (com ressalvas) |
| Filtragem no nível aplicativo | Nenhum | Mais baixo | Baixo | Melhor |
Para a maioria dos produtos SaaS com menos de 10.000 tenants, tabelas compartilhadas com RLS oferece a melhor relação custo-benefício. É no que nos aprofundaremos aqui.

Padrões de Arquitetura: Compartilhado vs Isolado
Antes mesmo de pensar em escrever código, você precisa escolher sua estratégia de resolução de tenant. Na prática, você encontrará principalmente duas abordagens:
Tenancy Baseada em Subdomínio
Já viu tenant-slug.yourapp.com? Bem-vindo ao padrão mais comum para SaaS B2B. É elegante, profissional e torna a resolução de tenant em middleware uma brisa.
Tenancy Baseada em Caminho
Essa é a básica /org/tenant-slug/dashboard. Mais fácil de configurar, já que não há DNS curinga, e funciona em plataformas como Vercel sem domínios personalizados. Mas vamos ser honestos: parece um pouco como usar meias com sandálias. Geralmente recomendamos subdomínio para aplicações B2B de produção e caminho para ferramentas internas ou MVPs. Mudar depois? Você amaldiçoará seu eu anterior—mudar esses padrões não é brincadeira.
Configurando o Schema do Tenant
Aqui está um padrão de schema que não nos decepcionou em três implementações diferentes em produção:
-- Tabela de tenant principal
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT now(),
settings JSONB DEFAULT '{}'
);
-- Tabela de junção de membros
CREATE TABLE memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, org_id)
);
-- Exemplo de tabela com escopo de tenant
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id) ON DELETE CASCADE NOT NULL,
name TEXT NOT NULL,
description TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now()
);
-- Índice em org_id — você precisará disso em CADA tabela com escopo de tenant
CREATE INDEX idx_projects_org_id ON projects(org_id);
CREATE INDEX idx_memberships_user_id ON memberships(user_id);
CREATE INDEX idx_memberships_org_id ON memberships(org_id);
A tabela memberships é a cola que mantém tudo junto. Todas as suas políticas RLS apontarão para ela como se fosse seu primo favorito. Usuários podem se juntar a várias organizações, e suas funções determinam o que eles podem ou não fazer. E aqui está uma pérola de sabedoria: sempre—seriamente, sempre—indexe org_id em cada tabela com escopo de tenant. Caso contrário, veja suas queries se arrastarem como melaço quando você estiver nadando em dados. Fomos pegos de surpresa por isso quando o painel de um cliente caiu de 50ms para 8 segundos com 100.000 linhas. Lição aprendida.
Políticas RLS que Realmente Escalam
É aqui que os tutoriais geralmente se rendem, deixando você abandonado. Eles atiram auth.uid() = user_id para você e dizem, "Boa sorte!" Mas RLS multi-tenant não pode ser reduzido assim.
O Padrão da Função Auxiliar
Por que poluir cada política com verificações de membros? Use uma função auxiliar:
-- Auxiliar: verificar se o usuário atual é membro de uma org
CREATE OR REPLACE FUNCTION public.is_member_of(org UUID)
RETURNS BOOLEAN AS $$
SELECT EXISTS (
SELECT 1 FROM memberships
WHERE user_id = auth.uid()
AND org_id = org
);
$$ LANGUAGE sql SECURITY DEFINER STABLE;
-- Auxiliar: obter a função do usuário em uma org
CREATE OR REPLACE FUNCTION public.get_role_in(org UUID)
RETURNS TEXT AS $$
SELECT role FROM memberships
WHERE user_id = auth.uid()
AND org_id = org
LIMIT 1;
$$ LANGUAGE sql SECURITY DEFINER STABLE;
Por que SECURITY DEFINER? Porque a função é executada com os privilégios do criador, ignorando RLS na tabela memberships. Sem isso, você corre o risco de cair em um buraco de coelho de dependência circular onde RLS em memberships quebra as verificações de membros que outras tabelas dependem.
E a parte STABLE? Sinaliza ao planejador de query que a saída da função permanece consistente para a mesma entrada durante uma única query, permitindo alguns benefícios de cache agradáveis. Tentado a usar IMMUTABLE? Não. A membros pode mudar entre transações.
Políticas para Tabelas com Escopo de Tenant
Vamos ver algumas políticas para nossa tabela projects:
-- Ativar RLS
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- SELECT: membros podem visualizar projetos em suas orgs
CREATE POLICY "Members can view org projects"
ON projects FOR SELECT
USING (public.is_member_of(org_id));
-- INSERT: admins e donos podem criar projetos
CREATE POLICY "Admins can create projects"
ON projects FOR INSERT
WITH CHECK (
public.get_role_in(org_id) IN ('owner', 'admin')
);
-- UPDATE: admins e donos podem atualizar projetos
CREATE POLICY "Admins can update projects"
ON projects FOR UPDATE
USING (public.is_member_of(org_id))
WITH CHECK (
public.get_role_in(org_id) IN ('owner', 'admin')
);
-- DELETE: apenas donos podem excluir projetos
CREATE POLICY "Owners can delete projects"
ON projects FOR DELETE
USING (
public.get_role_in(org_id) = 'owner'
);
Políticas para a Própria Tabela de Membros
Essa é complicada. A tabela memberships recebe seu próprio RLS, mas não pode usar as funções auxiliares porque elas, por sua vez, consultam memberships—fila pesadelos de referência circular:
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
-- Usuários podem ver membros em orgs às quais pertencem
CREATE POLICY "Users can view org memberships"
ON memberships FOR SELECT
USING (
org_id IN (
SELECT org_id FROM memberships WHERE user_id = auth.uid()
)
);
-- Apenas donos podem adicionar membros
CREATE POLICY "Owners can add members"
ON memberships FOR INSERT
WITH CHECK (
org_id IN (
SELECT org_id FROM memberships
WHERE user_id = auth.uid() AND role = 'owner'
)
);
Sim, há uma subconsulta na mesma tabela. E sim, PostgreSQL acerta isso. A subconsulta verifica sua própria membros, não afetada pela política sendo definida, pois RLS envolve apenas a query externa. Mas teste isso—seriamente, você não quer encontrar um bug em produção.

Middleware Next.js para Resolução de Tenant
Com Next.js 15 e o brilhante App Router, middleware rodando na borda é o administrador perfeito para resolução de tenant. Aqui está nosso padrão confiável para setups baseados em subdomínio:
// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
const PUBLIC_ROUTES = ['/login', '/signup', '/invite'];
export async function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const currentHost = hostname.split('.')[0];
// Pular para domínio principal e localhost
const isMainDomain = currentHost === 'app' || currentHost === 'www' || currentHost === 'localhost:3000';
let response = NextResponse.next({
request: { headers: request.headers },
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
response.cookies.set(name, value, options);
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!isMainDomain) {
response.headers.set('x-tenant-slug', currentHost);
if (!user && !PUBLIC_ROUTES.some(r => request.nextUrl.pathname.startsWith(r))) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
return response;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)'],
};
O cabeçalho x-tenant-slug é ouro puro. Use-o para deixar seus Server Components e rotas da API saberem qual tenant eles estão lidando. Se você está colaborando conosco em um projeto Next.js, configurar isso é nossa prioridade do primeiro dia.
Fluxo de Autenticação em Aplicações Multi-Tenant
Supabase Auth joga de forma neutra no jogo de multi-tenancy. Usuários existem em uma esfera global—as relações de tenant são seu quebra-cabeça. Aqui está nosso plano de jogo:
- Usuário se inscreve: Criar um usuário auth, construir uma organização e conjurar uma membros com função 'owner'.
- Usuário é convidado: O admin prepara um convite pendente, um novo usuário se junta via link de convite e puf—aparece uma membros com a função especificada.
- Usuário faz login: Extrair tenant do subdomínio, confirmar membros, conduzi-los ao painel.
// app/api/auth/signup/route.ts
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { email, password, orgName, orgSlug } = await request.json();
const supabase = await createClient();
// Inscrever o usuário
const { data: authData, error: authError } = await supabase.auth.signUp({
email,
password,
});
if (authError) return NextResponse.json({ error: authError.message }, { status: 400 });
// Usar um cliente com função de serviço para criação de org (ignora RLS)
const adminClient = createAdminClient();
const { data: org, error: orgError } = await adminClient
.from('organizations')
.insert({ name: orgName, slug: orgSlug })
.select()
.single();
if (orgError) return NextResponse.json({ error: orgError.message }, { status: 400 });
// Criar membros de proprietário
await adminClient
.from('memberships')
.insert({
user_id: authData.user!.id,
org_id: org.id,
role: 'owner',
});
return NextResponse.json({ org });
}
Observe que confiamos em um cliente com função de serviço durante a inscrição. O usuário ainda não tem nenhuma membros, então RLS o deixaria na mão para criação de organização. É um desses problemas clássicos de bootstrapping—sua chave de função de serviço será sua varinha mágica.
E não posso enfatizar isso o suficiente: Nunca, nunca exponha sua chave de função de serviço para o cliente. É estritamente para código do lado do servidor.
Server Components e RLS: O Problema SSR
Os Server Components do Next.js 15 estão vinculados ao servidor, aumentando o jogo de segurança. Mas há um problema ao usar Supabase RLS: você tem que fornecer a sessão do usuário ao cliente Supabase para que as políticas RLS saibam quem está na tabela.
// 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 {
// Isso pode falhar em Server Components (somente leitura)
// O middleware cuida da atualização de cookies
}
},
},
}
);
}
// app/[orgSlug]/projects/page.tsx
import { createClient } from '@/lib/supabase/server';
import { headers } from 'next/headers';
export default async function ProjectsPage() {
const supabase = await createClient();
const headersList = await headers();
const tenantSlug = headersList.get('x-tenant-slug');
// Obter o ID da org a partir do slug
const { data: org } = await supabase
.from('organizations')
.select('id')
.eq('slug', tenantSlug)
.single();
if (!org) return <div>Organization not found</div>;
// RLS filtra automaticamente — retorna apenas projetos
// onde o usuário atual tem membros
const { data: projects } = await supabase
.from('projects')
.select('*')
.eq('org_id', org.id)
.order('created_at', { ascending: false });
return (
<div>
{projects?.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
);
}
Aqui está o problema: mesmo que alguém remexer o org_id na request, RLS não se mexe. Bloqueia o acesso aos projetos a menos que o usuário seja um membro. Tecnicamente, .eq('org_id', org.id) é redundante por segurança—RLS cuida disso—mas é bom para performance e legibilidade.
Otimização de Performance e Armadilhas Comuns
O Problema de Query RLS N+1
Cada verificação de política RLS inicia uma subconsulta. Se você estiver conectando a uma verificação de política de 10 linhas quando está vendo 100 linhas, significa 100 rodadas de busca de membros. Felizmente, PostgreSQL é inteligente o suficiente para fazer cache—mas há overhead.
Correção: Use STABLE em funções auxiliares (como delineamos). Além disso, considere desnormalizar org_id nas reivindicações JWT:
-- Hook de JWT customizado (Supabase Dashboard > Auth > Hooks)
CREATE OR REPLACE FUNCTION public.custom_access_token_hook(event JSONB)
RETURNS JSONB AS $$
DECLARE
org_ids UUID[];
BEGIN
SELECT array_agg(org_id) INTO org_ids
FROM memberships
WHERE user_id = (event->>'user_id')::UUID;
event := jsonb_set(
event,
'{claims,org_ids}',
to_jsonb(org_ids)
);
RETURN event;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Então sua política RLS fica:
CREATE POLICY "Members can view"
ON projects FOR SELECT
USING (
org_id = ANY(
(SELECT array(SELECT jsonb_array_elements_text(
auth.jwt()->'org_ids'
))::UUID[])
)
);
Isso elimina completamente a busca da tabela de membros. Os IDs de org vêm direto do JWT. Ressalva: As reivindicações JWT são marcadas no login. Mude a membros de alguém e eles precisarão fazer nova autenticação para sincronizar as reivindicações. Tipicamente, isso é totalmente gerenciável—apenas mantenha em sua documentação.
Connection Pooling
Supabase fornece connection pooling através de PgBouncer. Se você está indo live com Next.js no Vercel, lembre-se: URL de pooler para rotas de API e server components.
# Para operações regulares (pooled)
DATABASE_URL=postgres://user:pass@db.project.supabase.co:6543/postgres
# Apenas para migrações (direto)
DIRECT_URL=postgres://user:pass@db.project.supabase.co:5432/postgres
Qualquer pessoa no Supabase Pro de $25 por mês recebe 200 conexões concorrentes via pooler. Para a maioria dos aplicações SaaS com menos de 1000 usuários concorrentes, é mais do que suficiente.
Índices que Você Absolutamente Precisa
Aqui está o conjunto de índices bruto para um setup multi-tenant:
-- Em cada tabela com escopo de tenant
CREATE INDEX idx_{table}_org_id ON {table}(org_id);
-- Índices compostos para queries comuns
CREATE INDEX idx_projects_org_created ON projects(org_id, created_at DESC);
-- Membros — muito consultada por RLS
CREATE INDEX idx_memberships_user_org ON memberships(user_id, org_id);
CREATE INDEX idx_memberships_org_role ON memberships(org_id, role);
EXPLAIN ANALYZE—melhor amigo de um desenvolvedor. Veja como suas queries se comportam com RLS embarcado. Você pode levar um choque sobre o que o planejador decide fazer sem os índices certos.
Testando Políticas RLS
Todos pulam isso, mas é sua melhor rede de segurança contra vazamentos de dados. Testamos políticas RLS direto em SQL:
-- Testar como um usuário específico
SET request.jwt.claims = '{"sub": "user-uuid-here", "role": "authenticated"}';
SET role = 'authenticated';
-- Isso deve retornar apenas projetos aos quais o usuário tem acesso
SELECT * FROM projects;
-- Isso deve falhar (usuário não é membro dessa org)
INSERT INTO projects (org_id, name) VALUES ('other-org-uuid', 'Sneaky Project');
-- Resetar
RESET role;
E não esqueça pgTAP para políticas críticas:
BEGIN;
SELECT plan(3);
-- Configurar contexto de teste como usuário A (membro da org 1)
SET LOCAL request.jwt.claims = '{"sub": "user-a-uuid"}';
SET LOCAL role = 'authenticated';
SELECT is(
(SELECT count(*) FROM projects WHERE org_id = 'org-1-uuid')::INTEGER,
5,
'User A sees 5 projects in their org'
);
SELECT is(
(SELECT count(*) FROM projects WHERE org_id = 'org-2-uuid')::INTEGER,
0,
'User A sees 0 projects in other org'
);
SELECT throws_ok(
$$INSERT INTO projects (org_id, name) VALUES ('org-2-uuid', 'Hack')$$,
'new row violates row-level security policy',
'User A cannot insert into other org'
);
SELECT * FROM finish();
ROLLBACK;
Execute isso em CI. Cada migração que brinca com políticas RLS deve enviar a suite de testes completa por um treino vigoroso.
Checklist de Deployment em Produção
Pronto para enviar? Brinde-se com isso:
- RLS ativado em cada tabela que contém dados de tenant
- Chave de função de serviço guardada no lado do servidor, longe de um cliente
-
org_idadequadamente indexado em todas as tabelas com escopo de tenant - Funções auxiliares de membros reconhecidas como
SECURITY DEFINEReSTABLE - Reivindicações JWT customizadas travadas e carregadas (se na rota JWT)
- Tem connection pooling conectado para deployment em nuvem?
- Políticas RLS saídas frescos do QA testando com pgTAP ou similar
- Ligado
EXPLAIN ANALYZEem queries cruciais com RLS rodando - Fluxo de convite/inscrição não perdendo nenhum bootstrap de membros?
- Alguma limitação de taxa em endpoints de auth? Supabase oferece opções integradas
- Ligou o switch em RLS para tabelas do esquema
authno Supabase Dashboard (frequentemente uma mina terrestre) - Monitoramento embarcado para qualquer query lenta (Supabase Dashboard > Database > Query Performance)
Lançando um produto multi-tenant e quer alguém que já vadeou essas águas antes? Nossas soluções de desenvolvimento de CMS headless ou uma rápida conversa através da nossa página de contato pode ser exatamente o que você precisa.
FAQ
Posso usar Supabase RLS para apps com milhares de tenants?
Absolutamente. Pilotamos RLS de tabela compartilhada com mais de 5.000 tenants e milhões de linhas sem suar. A salsa secreta? Indexação adequada em colunas org_id e funções auxiliares STABLE. Considerando 50.000+ tenants ou extravagâncias de bilhão de linhas? Mergulhe em particionamento de tabelas por org_id ou flerte com um setup de schema-por-tenant.
Como lido com troca de tenant quando um usuário pertence a várias organizações?
Mantenha a organização ativa escondida em um cookie ou URL (subdomínio). Trocar orgs? Ajuste o subdomínio/cookie e busque de novo. Não esconda a org ativa no JWT—exige um novo login para mudar. Um cookie que seu middleware pode espreitar é o caminho.
O que acontece se eu esquecer de ativar RLS em uma tabela?
Cada usuário autenticado poderia tocar cada linha. Essa é a posição padrão do PostgreSQL—nenhuma restrição de linha em tabelas sem RLS. Supabase Dashboard sinaliza tabelas faltando RLS, mas integrar isso em CI com queries para pg_tables e pg_policies ajuda também.
Devo usar a chave de função de serviço do Supabase ou cozinhar uma função PostgreSQL customizada para tarefas de admin?
Principalmente, a chave de função de serviço é suficiente. Ela ignora RLS completamente, então é seu segredo de topo para uso apenas no lado do servidor. Precisa de governança granular (como uma função "admin" lurindo em todas as orgs mas tímida de exclusões)? Esse é território PostgreSQL customizado—avançado e geralmente fora do seu radar até que ferramentas internas complexas exijam isso.
Como executo migrações de banco de dados sem tropeçar em políticas RLS?
A CLI do Supabase (supabase db push ou supabase migration) ao lado da URL do banco de dados direto (ignorando pooled) tem suas costas. Esconda edições de política RLS na mesma migração como tweaks de schema. Teste cast migrações contra um projeto staging—Supabase permite girar branches de preview no Pro exatamente para esse tipo de coisa.
Políticas RLS podem alcançar dados de outras APIs ou serviços?
Não. Políticas RLS ficam aconchegantes em SQL, avaliadas por PostgreSQL. Saudades de verificar dados externos (como um serviço de feature flag)? Cimente esses dados em uma tabela de banco de dados e referencie em sua política. Um padrão típico é sincronizar statuses de assinatura de Stripe para uma coluna organizations.plan.
Qual é o imposto de performance do RLS comparado com filtragem na camada de aplicativo?
Em nossos benchmarks do Supabase Pro (2 vCPUs, 8GB RAM), RLS adiciona 1-3ms extras por query para políticas básicas de verificação de membros com os índices certos. Vá à loucura com complexidade de política ou joins e você pode adicionar 5-15ms. A tática de reivindicações JWT (armazenar org_ids no token) o fatia abaixo de 1ms já que não há dança de subconsulta. Para aplicações web típicas, aquele filete de latência é negligenciável.
Como isso funciona com assinaturas Supabase Realtime?
Supabase Realtime brinca pelas regras do RLS. Sintonize mudanças de tabela e pegue apenas eventos de linhas às quais você é elegível para ver de acordo com RLS. Isso se desenrola da caixa com zero tindering extra. Apenas certifique-se de que seu Supabase do lado do cliente tem a sessão do usuário, que @supabase/ssr trata perfeitamente.