Multi-Tenancy
Rules
- Row-level isolation (default): add
tenantId column to every tenant-scoped table — enforce via middleware or RLS (Row-Level Security)
- Schema-per-tenant: use when tenants need custom schemas or strict data isolation — more complex ops, better isolation
- Subdomain routing: parse tenant from
{tenant}.app.com — resolve in middleware, set tenantId on request context
- Custom domains: CNAME to your app, lookup tenant by
Host header — use a domains table mapping custom domains to tenant IDs
- Per-tenant config: store theme (logo, colors), feature flags, and limits in a
tenant_settings table — cache in memory with TTL
- RLS pattern (Postgres):
ALTER TABLE posts ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation ON posts USING (tenant_id = current_setting('app.tenant_id'))
- Middleware: extract tenant from subdomain/header, set on context, apply to all database queries — never let a query run without tenant scope
- Data partitioning: for large tables, partition by
tenantId — improves query performance and enables per-tenant backup/restore
Row-Level Isolation Pattern
function getTenantFromHost(host: string): string {
const subdomain = host.split(".")[0];
return subdomain;
}
prisma.$use(async (params, next) => {
if (TENANT_MODELS.includes(params.model)) {
params.args.where = { ...params.args.where, tenantId: context.tenantId };
}
return next(params);
});
Subdomain Routing (Next.js Middleware)
export function middleware(request: NextRequest) {
const host = request.headers.get("host") ?? "";
const tenant = host.split(".")[0];
const response = NextResponse.next();
response.headers.set("x-tenant-id", tenant);
return response;
}
Avoid
- Queries without tenant scope — one missing
WHERE tenantId = ? leaks data across tenants
- Hardcoding tenant in client — resolve from subdomain/domain server-side
- No index on
tenantId — every tenant-scoped table needs a composite index starting with tenantId
- Mixing tenant data in shared caches — namespace cache keys with
{tenantId}:{key}