DEV Community

huangyongshan46-a11y
huangyongshan46-a11y

Posted on

Next.js 16 Multi-Tenancy: Organizations, Team Invites, and Per-Org Billing

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

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*"],
};
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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

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)