SaaS multi-tenant com Prisma e Next.js: o jeito chato e o jeito certo
Três estratégias pra isolar dados de cliente num SaaS, qual eu escolhi pro HubMenu, e o middleware que evita SELECT esquecendo o tenant_id.
Quando você está construindo um SaaS B2B, a primeira pergunta de arquitetura é: "como eu separo os dados de cliente A do cliente B?". Errar isso significa ou vazar dado entre tenants, ou ter um banco que não escala. Os dois são problema.
No HubMenu (SaaS de cardápios digitais) decidi cedo. Aqui vão as 3 opções que considerei e por que escolhi a que escolhi.
Opção 1: Banco por tenant
Cada cliente, um Postgres. Isolamento total — impossível vazar.
Quando faz sentido: SaaS enterprise com 10-50 clientes pagando muito, exigência de compliance forte (LGPD/HIPAA), ou cliente que quer hospedar em servidor próprio.
Por que descartei: HubMenu é self-serve, vai ter centenas de restaurantes. 1 banco por restaurante = pesadelo de migração e custo.
Opção 2: Schema por tenant
Um banco, vários schemas (tenant_a.users, tenant_b.users).
Isolamento médio — Postgres permite. Prisma suporta com previewFeatures = ["multiSchema"].
Por que descartei: migrações ficam complicadas (precisa rodar em N schemas), backup/restore vira artesanato. Ganho marginal em isolamento não compensa.
Opção 3: Coluna tenant_id em tudo (escolhida)
Um schema, todas as tabelas com tenantId. Toda query filtra por ele.
model Tenant {
id String @id @default(cuid())
slug String @unique
menus Menu[]
users User[]
}
model Menu {
id String @id @default(cuid())
name String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
// ...
@@index([tenantId])
}
model User {
id String @id @default(cuid())
email String @unique
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
}
Pró: simples, escala, migrations triviais.
Contra: se você esquece de filtrar por tenantId numa query, vaza dado.
Como evitar o vazamento: extension do Prisma
A solução é não confiar em desenvolvedor lembrar. Em vez disso, um client-side guard:
// lib/db.ts
import { PrismaClient } from "@prisma/client";
import { headers } from "next/headers";
export function dbForTenant(tenantId: string) {
return new PrismaClient().$extends({
query: {
$allModels: {
async $allOperations({ args, query, model }) {
// tabelas que NÃO têm tenantId (ex: Tenant em si)
const skip = ["Tenant"];
if (skip.includes(model ?? "")) return query(args);
args.where = { ...args.where, tenantId };
return query(args);
},
},
},
});
}
Toda query feita pelo client retornado automaticamente vira
WHERE tenantId = ?. Esquecer de filtrar deixa de ser possível.
Resolvendo o tenant atual
Pega do subdomínio (acme.hubmenu.com) ou do path (/acme/dashboard).
Eu uso subdomain via middleware Next.js:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const host = req.headers.get("host") ?? "";
const subdomain = host.split(".")[0];
// se for "www" ou domínio raiz, não tem tenant
if (subdomain === "www" || subdomain === "hubmenu") {
return NextResponse.next();
}
const res = NextResponse.next();
res.headers.set("x-tenant-slug", subdomain);
return res;
}
Server Component pega headers() e converte slug → tenantId via cache.
O que aprendi do jeito difícil
- Sempre
@@index([tenantId]). Sem isso, toda query vira full scan. - Cookie de auth tem que ter o tenantId. Senão você deixa lacuna entre login e first-query.
- Teste com 2 tenants em desenvolvimento. Bug de vazamento só aparece quando você usa o produto como cliente diferente.
Multi-tenant não é difícil — é detalhista. Errar nos detalhes vaza dado em produção. Acertar nos detalhes vira feature: isolamento de cliente, billing por uso, white-label, tudo cai no colo.