Multi-tenancy is the part of B2B SaaS that quietly keeps founders up at night. Get it wrong and one customer sees another customer's data β the kind of bug that ends a company. This guide covers the pragmatic pattern for building multi-tenant apps in Next.js 15 + Prisma + Auth.js: shared database, row-level isolation, and a discipline that makes leaks structurally hard.
A tenant is a customer organization. The common, cost-effective model is a single shared database where every business row carries an organizationId, and every query filters by it. (Separate DB-per-tenant exists but is operationally heavy; most SaaS starts shared.) The whole game is: no query ever runs without the tenant filter.
The failure mode is treating isolation as a convention β "remember to add where: { organizationId }." Someone eventually forgets, and that's your data leak. Instead, resolve the caller's tenant context in one place, on every request:
// lib/tenant.ts
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export class UnauthorizedError extends Error {}
// Resolves who is calling and which org they're acting in β server-side,
// from the session. Never trust an organizationId from the client body.
export async function requireMembership(orgSlug?: string) {
const session = await auth();
if (!session?.user?.id) throw new UnauthorizedError("Not signed in");
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
...(orgSlug ? { organization: { slug: orgSlug } } : {}),
},
include: { organization: true },
});
if (!membership) throw new UnauthorizedError("No org access");
return {
userId: session.user.id,
organizationId: membership.organizationId,
role: membership.role, // OWNER | ADMIN | MEMBER
};
}
Now every handler starts the same way, and the organizationId it filters by comes from the session, not from user input:
// app/api/projects/route.ts
export async function GET() {
const { organizationId } = await requireMembership();
const projects = await prisma.project.findMany({
where: { organizationId }, // scoped β always
});
return Response.json({ data: projects });
}
organizationId from the request body or query string. If the client can name the org, they can name someone else's org. The tenant must be derived server-side from the authenticated session's membership.Roles decide what a member can do within their org. Don't scatter if (role === "admin") everywhere β centralize it in a permission matrix:
// lib/rbac.ts
const PERMISSIONS = {
OWNER: ["project:*", "member:*", "billing:*"],
ADMIN: ["project:*", "member:invite"],
MEMBER: ["project:read"],
} as const;
export class ForbiddenError extends Error {}
export function assertCan(role: string, action: string) {
const granted = PERMISSIONS[role] ?? [];
const ok = granted.some(p => p === action ||
(p.endsWith(":*") && action.startsWith(p.slice(0, -1))));
if (!ok) throw new ForbiddenError(`${role} cannot ${action}`);
}
Then a delete is two honest lines: const { role } = await requireMembership(); assertCan(role, "project:delete");
findUnique by id β fetching a row by its primary key alone skips the tenant filter. Scope it: where: { id, organizationId }.You can absolutely build all of this β it's a good exercise and the pattern above is the core of it. But wiring auth, org/membership models, the tenant resolver, RBAC, billing hooks, API keys, and audit logs cleanly takes most people 40β80 hours before they write a single feature.
π Skip the plumbing β SaaS Starter βorganizationId on every business row.{ userId, organizationId, role } centrally, from the session β never the client.organizationId, including id lookups and joins.