Multi-tenancy is one of those things that sounds straightforward until you actually build it. Every user belongs to a workspace. Data is isolated per workspace. Roles control what each user can do inside that workspace.
Get it wrong and you end up with data leaking between customers, permission bugs, or an architecture that falls apart when you try to add team invites six months later.
I built this from scratch while working on a SaaS product. Here is everything I learned — the schema design, the session pattern, the data isolation approach, and the role system — with real code you can use.
What multi-tenancy actually means
In a single-tenant app, one installation serves one customer. In a multi-tenant app, one installation serves many customers simultaneously, with each customer's data completely isolated from every other customer.
In practice for a B2B SaaS this means:
- User signs up → a workspace (Organization) is created for them
- That user is the Owner of their workspace
- They can invite teammates who become Members
- Every piece of data (jobs, invoices, whatever your app does) belongs to an Organization
- A user in Organization A can never see data from Organization B
The data model
This is the foundation everything else builds on. Get this right first.
// prisma/schema.prisma
model User {
id String @id @default(cuid())
name String?
email String @unique
password String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
@@index([email])
}
model Organization {
id String @id @default(cuid())
name String
slug String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
memberships Membership[]
invites Invite[]
@@index([slug])
}
model Membership {
id String @id @default(cuid())
userId String
organizationId String
role Role @default(MEMBER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@unique([userId, organizationId])
@@index([organizationId])
}
model Invite {
id String @id @default(cuid())
email String
role Role @default(MEMBER)
token String @unique
organizationId String
expiresAt DateTime
createdAt DateTime @default(now())
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([email])
@@index([organizationId])
}
enum Role {
OWNER
ADMIN
MEMBER
}
The key relationship is Membership — it sits between User and Organization and carries the role. This means:
- One user can belong to multiple organizations
- Each membership has its own role
- Deleting an organization cascades to memberships automatically
Run the migration:
npx prisma migrate dev --name init
npx prisma generate
Creating an organization on signup
When a user signs up, you create their account and their organization in a single transaction. If either fails, both roll back — you never end up with a user who has no organization.
// src/app/api/auth/signup/route.ts
import { prisma } from "@/lib/db";
import bcrypt from "bcryptjs";
import { cookies } from "next/headers";
import { signToken } from "@/lib/auth";
export async function POST(req: Request) {
const body = await req.text();
const { name, email, password, organizationName } = JSON.parse(body);
if (!name || !email || !password || !organizationName) {
return Response.json(
{ error: "All fields are required." },
{ status: 400 }
);
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return Response.json(
{ error: "Email already registered." },
{ status: 409 }
);
}
const hashedPassword = await bcrypt.hash(password, 10);
// Generate a URL-safe slug from the org name
const slug = `${organizationName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")}-${Date.now()}`;
// Single transaction — user + org + membership created together
const { user, organization } = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: { name, email, password: hashedPassword },
});
const organization = await tx.organization.create({
data: {
name: organizationName,
slug,
memberships: {
create: { userId: user.id, role: "OWNER" },
},
},
});
return { user, organization };
});
// Issue JWT with both userId and orgId
const token = signToken({ userId: user.id, orgId: organization.id });
(await cookies()).set("token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 7, // 7 days
});
return Response.json({ success: true });
}
Notice that the JWT contains both userId and orgId. This is the key to multi-tenancy — every authenticated request knows both who the user is and which organization they are acting in.
The JWT auth helpers
// src/lib/auth.ts
import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET!;
export function signToken(payload: object) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: "7d" });
}
export function verifyToken(token: string) {
try {
return jwt.verify(token, JWT_SECRET) as {
userId: string;
orgId: string;
iat: number;
exp: number;
};
} catch {
return null;
}
}
Generate a secure secret:
openssl rand -hex 32
Never use a placeholder string. Anyone with your JWT secret can forge tokens and access any account.
Reading the current user and organization
Create two helpers you can call from any server component or API route:
// src/lib/current-user.ts
import { cookies } from "next/headers";
import { verifyToken } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function getCurrentUser() {
const token = (await cookies()).get("token")?.value;
const payload = token ? verifyToken(token) : null;
if (!payload?.userId) return null;
return prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, name: true, email: true },
});
}
// src/lib/current-organization.ts
import { cookies } from "next/headers";
import { verifyToken } from "@/lib/auth";
import { prisma } from "@/lib/db";
export async function getCurrentOrganization() {
const token = (await cookies()).get("token")?.value;
const payload = token ? verifyToken(token) : null;
if (!payload?.orgId) return null;
return prisma.organization.findUnique({
where: { id: payload.orgId },
});
}
Use them in any server component:
const user = await getCurrentUser();
const organization = await getCurrentOrganization();
if (!user || !organization) redirect("/login");
Data isolation — the most important part
Every query against your application data must filter by organizationId. This is non-negotiable. Miss it once and you have a data leak between tenants.
// ✅ Correct — always scope to the current org
const jobs = await prisma.job.findMany({
where: { organizationId: organization.id },
});
// ❌ Wrong — returns data from ALL organizations
const jobs = await prisma.job.findMany();
A practical pattern is to add the organizationId check as the first thing in every API route:
export async function GET(req: Request) {
const organization = await getCurrentOrganization();
if (!organization) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// All queries from here use organization.id
const data = await prisma.yourModel.findMany({
where: { organizationId: organization.id },
});
return Response.json(data);
}
Middleware to protect dashboard routes
// src/proxy.ts (referenced from middleware.ts)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(req: NextRequest) {
const token = req.cookies.get("token")?.value;
const isAuthPage =
req.nextUrl.pathname.startsWith("/login") ||
req.nextUrl.pathname.startsWith("/signup");
const isDashboard = req.nextUrl.pathname.startsWith("/dashboard");
if (!token && isDashboard) {
return NextResponse.redirect(new URL("/login", req.url));
}
if (token && isAuthPage) {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
return NextResponse.next();
}
// src/middleware.ts
export { proxy as middleware } from "@/proxy";
export const config = {
matcher: ["/dashboard/:path*", "/login", "/signup"],
};
Role-based permissions
Check the current user's role before any privileged action:
// src/app/api/team/invite/route.ts
export async function POST(req: Request) {
const organization = await getCurrentOrganization();
const user = await getCurrentUser();
if (!organization || !user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
// Only OWNER or ADMIN can invite
const membership = await prisma.membership.findUnique({
where: {
userId_organizationId: {
userId: user.id,
organizationId: organization.id,
},
},
});
if (!membership || membership.role === "MEMBER") {
return Response.json(
{ error: "Only Owners and Admins can invite members." },
{ status: 403 }
);
}
// Proceed with invite creation...
}
The @@unique([userId, organizationId]) constraint on Membership means findUnique with userId_organizationId always works — no duplicate memberships possible.
Team invites
import crypto from "crypto";
// Create invite
const token = crypto.randomBytes(32).toString("hex");
const invite = await prisma.invite.create({
data: {
email,
role,
token,
organizationId: organization.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
// Invite URL
const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/${invite.token}`;
Accepting the invite:
// src/app/api/team/accept-invite/route.ts
export async function POST(req: Request) {
const user = await getCurrentUser();
if (!user) {
return Response.json({ error: "Login required." }, { status: 401 });
}
const { token } = JSON.parse(await req.text());
const invite = await prisma.invite.findUnique({ where: { token } });
if (!invite) {
return Response.json({ error: "Invalid invite." }, { status: 404 });
}
if (invite.expiresAt < new Date()) {
return Response.json({ error: "Invite has expired." }, { status: 410 });
}
// Add user to org
await prisma.membership.create({
data: {
userId: user.id,
organizationId: invite.organizationId,
role: invite.role,
},
});
// Delete the invite — single use
await prisma.invite.delete({ where: { token } });
return Response.json({ success: true });
}
Displaying organization data in the dashboard
A server component that reads real data:
// src/app/dashboard/team/page.tsx
import { cookies } from "next/headers";
import { verifyToken } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { redirect } from "next/navigation";
export default async function TeamPage() {
const token = (await cookies()).get("token")?.value;
const payload = token
? verifyToken(token) as { userId?: string; orgId?: string } | null
: null;
if (!payload?.orgId) redirect("/login");
const members = await prisma.membership.findMany({
where: { organizationId: payload.orgId },
include: {
user: { select: { name: true, email: true } },
},
orderBy: { createdAt: "asc" },
});
return (
<div>
<h1>Team — {members.length} members</h1>
{members.map((member) => (
<div key={member.id}>
<p>{member.user.name ?? member.user.email}</p>
<p>{member.role}</p>
</div>
))}
</div>
);
}
Common mistakes to avoid
Forgetting organizationId in queries
Every query must be scoped to the current org. One unscoped query returns data from all tenants. Add an ESLint rule or code review checklist specifically for this.
Using userId where organizationId is needed
If your app has a Subscription or Settings model, it should belong to an Organization, not a User. Users come and go. The organization is the billing and data unit.
Not using transactions for signup
If user creation succeeds but organization creation fails, you have a user with no organization and no way to recover. Always use prisma.$transaction for multi-model creates.
Storing orgId only in the session without validating membership
A user could modify a stored orgId to access a different org. Always verify the user is actually a member of the organization in the JWT before returning data.
What to build next
Once you have this foundation:
- Subscription billing — attach a Subscription to the Organization, not the User
- Feature gating — check the org's subscription plan before returning data
- Audit logging — log every significant action with userId and organizationId
- Organization settings — let Owners rename the org, upload a logo
Skip the setup
I packaged everything in this article — plus Paddle Billing integration, feature gating, Resend transactional email, a polished dashboard, and full documentation — into a production-ready boilerplate.
If you want to skip straight to building your actual product:
Live demo: https://nextsaasstarter.vercel.app/
Get the boilerplate: https://stackfoundry.gumroad.com/l/retljp
Questions? Drop them in the comments.
Top comments (0)