🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)
🕵️ SicherheitslückenHak5: Hackers Just Poisoned the Rust Supply Chain | Threat Wire(01.09.2026 um 14:00 Uhr)
🕵️ SicherheitslückenHak5: Hackers Found a Way Into Humanoid Robots | Threat Wire(04.09.2026 um 15:04 Uhr)
🔧 AI Nachrichten Bits und so #1021 (Passwort für Laufwerk)(31.08.2026 um 22:15 Uhr)
🔧 AI Nachrichten Bits und so #1022 (Wie Weißbier)(06.09.2026 um 20:39 Uhr)
🍏 iOS / Mac OSHue-App 6.0 ist da: das sind die Neuerungen(07.09.2026 um 17:21 Uhr)

🔧 Programmierung 🕛 kürzlich 9 Min Lesezeit
0

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

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

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.




CODE
// 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:




CODE
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.




CODE
// 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






CODE
// 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:




CODE
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:




CODE
// 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 },
});
}









CODE
// 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:




CODE
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.




CODE
// ✅ 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:




CODE
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






CODE
// 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();
}









CODE
// 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:




CODE
// 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






CODE
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:




CODE
// 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:




CODE
// 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:



Questions? Drop them in the comments.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Hackers Just Poisoned the Rust Supply Chain | Threat Wire
1 Quelle
Hackers Found a Way Into Humanoid Robots | Threat Wire
1 Quelle
Bits und so #1021 (Passwort für Laufwerk)
Ähnliche Beiträge
🔍 Verwandte News

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

Thematisch verwandte Begriffe: Build, MultiTenant, SaaS, with · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...