Multi-Tenancy in Next.js: Scope Every Query the Safe Way

Updated July 2026 Β· ~7 min read

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.

What "multi-tenant" actually means

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 core rule: resolve tenant context centrally

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 });
}
⚠️ Never take 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.

Layer RBAC on top

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");

The mistakes that cause leaks

Build it yourself, or start from a done version

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 β†’
A production-ready Next.js 15 boilerplate with exactly this multi-tenancy pattern, RBAC, signature-verified Stripe billing, hashed API keys, and audit logs. Free Lite version on GitHub; full version one-time $99.

Takeaways

  1. Shared DB + organizationId on every business row.
  2. Resolve { userId, organizationId, role } centrally, from the session β€” never the client.
  3. Filter every query by organizationId, including id lookups and joins.
  4. Centralize permissions in one matrix; add a per-org audit log early.