DEV Community

Cover image for How to Build Multi-Tenant SaaS with Next.js 16 and Prisma
Carey Tobore
Carey Tobore

Posted on

How to Build Multi-Tenant SaaS with Next.js 16 and Prisma

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Generate a secure secret:

openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

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 },
  });
}
Enter fullscreen mode Exit fullscreen mode
// 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 },
  });
}
Enter fullscreen mode Exit fullscreen mode

Use them in any server component:

const user = await getCurrentUser();
const organization = await getCurrentOrganization();

if (!user || !organization) redirect("/login");
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode
// src/middleware.ts
export { proxy as middleware } from "@/proxy";

export const config = {
  matcher: ["/dashboard/:path*", "/login", "/signup"],
};
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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}`;
Enter fullscreen mode Exit fullscreen mode

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 });
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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)