Next.js 16 Multi-Tenancy: Organizations, Team Invites, and Per-Org Billing
Multi-tenancy is the backbone of any serious SaaS product. When users need to collaborate under a shared workspace — invite teammates, manage billing together, and work on shared data — you need a clean organizational layer beneath your auth system. This tutorial walks through building exactly that with Next.js 16, Prisma, and Stripe.
By the end, you'll have:
- A Prisma schema supporting orgs, memberships, and roles
- Middleware that injects org context into every request
- A team invite flow via email
- Per-org Stripe subscriptions
1. The Prisma Schema
First, the data model. Organizations sit between users and resources. A user can belong to many orgs, each org has members with roles, and billing attaches to the org — not the individual user.
// prisma/schema.prisma
model Organization {
id String @id @default(cuid())
name String
slug String @unique
stripeCustomerId String? @unique
stripeSubId String?
plan String @default("free")
createdAt DateTime @default(now())
members Member[]
invites Invite[]
}
model Member {
id String @id @default(cuid())
orgId String
userId String
role Role @default(MEMBER)
createdAt DateTime @default(now())
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([orgId, userId])
}
model Invite {
id String @id @default(cuid())
orgId String
email String
role Role @default(MEMBER)
token String @unique @default(cuid())
expiresAt DateTime
accepted Boolean @default(false)
org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
}
enum Role {
OWNER
ADMIN
MEMBER
}
Run npx prisma migrate dev --name add-multi-tenancy to apply.
2. Org Context Middleware
Every request needs to know which org it belongs to. The cleanest approach: store activeOrgId in the session, then verify membership in middleware before the request hits any route handler.
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
export async function middleware(req: NextRequest) {
const token = await getToken({ req });
if (!token) {
return NextResponse.redirect(new URL("/login", req.url));
}
const orgSlug = req.nextUrl.pathname.split("/")[2]; // e.g. /app/[orgSlug]/...
if (!orgSlug) return NextResponse.next();
// Verify membership — attach org info to headers for server components
const res = await fetch(
`${req.nextUrl.origin}/api/org/${orgSlug}/verify-member`,
{
headers: { cookie: req.headers.get("cookie") ?? "" },
}
);
if (!res.ok) {
return NextResponse.redirect(new URL("/app", req.url));
}
const { orgId, role } = await res.json();
const response = NextResponse.next();
response.headers.set("x-org-id", orgId);
response.headers.set("x-org-role", role);
return response;
}
export const config = {
matcher: ["/app/:orgSlug/:path*"],
};
And the verify endpoint:
// app/api/org/[slug]/verify-member/route.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextResponse } from "next/server";
export async function GET(
req: Request,
{ params }: { params: { slug: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) return new NextResponse("Unauthorized", { status: 401 });
const member = await prisma.member.findFirst({
where: {
userId: session.user.id,
org: { slug: params.slug },
},
include: { org: true },
});
if (!member) return new NextResponse("Forbidden", { status: 403 });
return NextResponse.json({ orgId: member.orgId, role: member.role });
}
3. Team Invite Flow
Invites work via a signed token emailed to the recipient. When they click the link, they're added to the org.
// app/api/org/[slug]/invite/route.ts
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { sendEmail } from "@/lib/email";
import { NextResponse } from "next/server";
export async function POST(
req: Request,
{ params }: { params: { slug: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) return new NextResponse("Unauthorized", { status: 401 });
const { email, role } = await req.json();
// Verify inviter is OWNER or ADMIN
const inviter = await prisma.member.findFirst({
where: { userId: session.user.id, org: { slug: params.slug } },
});
if (!inviter || inviter.role === "MEMBER") {
return new NextResponse("Forbidden", { status: 403 });
}
const org = await prisma.organization.findUnique({
where: { slug: params.slug },
});
if (!org) return new NextResponse("Not found", { status: 404 });
const invite = await prisma.invite.create({
data: {
orgId: org.id,
email,
role,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
const inviteUrl = `${process.env.NEXT_PUBLIC_URL}/invite/${invite.token}`;
await sendEmail({
to: email,
subject: `You've been invited to join ${org.name}`,
html: `<p>Click <a href="${inviteUrl}">here</a> to join the team. Expires in 7 days.</p>`,
});
return NextResponse.json({ success: true });
}
The accept route:
// app/api/invite/[token]/accept/route.ts
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
export async function POST(
req: Request,
{ params }: { params: { token: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) return new NextResponse("Unauthorized", { status: 401 });
const invite = await prisma.invite.findUnique({
where: { token: params.token },
});
if (!invite || invite.accepted || invite.expiresAt < new Date()) {
return new NextResponse("Invalid or expired invite", { status: 400 });
}
await prisma.$transaction([
prisma.member.create({
data: {
orgId: invite.orgId,
userId: session.user.id,
role: invite.role,
},
}),
prisma.invite.update({
where: { id: invite.id },
data: { accepted: true },
}),
]);
return NextResponse.json({ orgId: invite.orgId });
}
4. Per-Org Stripe Subscriptions
Billing attaches to the organization, not the user. When an org upgrades, create a Stripe customer for the org if one doesn't exist, then start a checkout session.
// app/api/org/[slug]/billing/checkout/route.ts
import Stripe from "stripe";
import { prisma } from "@/lib/prisma";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { NextResponse } from "next/server";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2025-01-27.acacia",
});
export async function POST(
req: Request,
{ params }: { params: { slug: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) return new NextResponse("Unauthorized", { status: 401 });
const org = await prisma.organization.findUnique({
where: { slug: params.slug },
});
if (!org) return new NextResponse("Not found", { status: 404 });
// Create Stripe customer if first checkout
let customerId = org.stripeCustomerId;
if (!customerId) {
const customer = await stripe.customers.create({
name: org.name,
metadata: { orgId: org.id },
});
customerId = customer.id;
await prisma.organization.update({
where: { id: org.id },
data: { stripeCustomerId: customerId },
});
}
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
line_items: [
{
price: process.env.STRIPE_PRO_PRICE_ID!,
quantity: 1,
},
],
success_url: `${process.env.NEXT_PUBLIC_URL}/app/${params.slug}/settings?upgraded=1`,
cancel_url: `${process.env.NEXT_PUBLIC_URL}/app/${params.slug}/settings`,
metadata: { orgId: org.id },
});
return NextResponse.json({ url: checkoutSession.url });
}
Wire up a webhook to handle customer.subscription.updated and customer.subscription.deleted to keep plan and stripeSubId in sync on the org:
// In your Stripe webhook handler
case "customer.subscription.updated":
case "customer.subscription.created": {
const sub = event.data.object as Stripe.Subscription;
const org = await prisma.organization.findFirst({
where: { stripeCustomerId: sub.customer as string },
});
if (org) {
await prisma.organization.update({
where: { id: org.id },
data: {
stripeSubId: sub.id,
plan: sub.status === "active" ? "pro" : "free",
},
});
}
break;
}
Tying It Together
With this in place, every route under /app/[orgSlug] is protected by membership verification. Invites are token-based with expiry. Billing is cleanly scoped to the org so any member can trigger an upgrade, and the subscription status flows back via webhook.
A few things to add as you scale:
- Seat limits — cap membership count based on the org's plan
- Org switcher UI — let users flip between orgs they belong to
- Audit log — track role changes and invites for compliance
Skip the Boilerplate
If you'd rather not wire all of this up from scratch, LaunchKit ships with the full multi-tenancy stack pre-built: org creation, member management, role-based access, email invites, and per-org Stripe subscriptions — all in a production-ready Next.js 16 codebase.
The GitHub repo has the full source so you can see exactly how it's structured before buying.
Building something with Next.js? Drop your questions in the comments — happy to help.
Top comments (0)