Multi-Tenant SaaS

Build a Multi-Tenant SaaS Application with Next.js from Scratch

Most SaaS tutorials stop at login. Real products — Notion, ClickUp, Linear, Slack, Trello — are built on workspaces, organizations, roles, billing, and strict tenant isolation. This guide walks you through that full architecture in Next.js 15, from database schema to Stripe subscriptions and production deployment.

✓ Workspaces✓ RLS✓ Stripe✓ Subdomains

Ship SaaS like the pros

Organizations, roles, invitations, projects, billing, and admin — one cohesive system, not a collection of disconnected features.

What We're Building

A multi-tenant SaaS platform where each organization is an isolated tenant. Users belong to one or more organizations via memberships, each with a role. Inside an organization, teams manage projects, invite teammates, and pay via Stripe subscriptions. An internal admin panel lets your team manage customers without touching production data directly.

Core entities

  • Users (auth via Supabase)
  • Organizations (tenants)
  • Memberships (user ↔ org + role)
  • Invitations (pending members)
  • Projects (org-scoped resources)
  • Subscriptions (Stripe-linked billing)

Cross-cutting concerns

  • Tenant context on every request
  • Permission checks at middleware + RLS
  • Subdomain or slug-based routing
  • Server Actions for mutations
  • Audit logs for admin actions
Multi-tenancy model: We use a shared database with a organization_id column on every tenant-scoped table, enforced by PostgreSQL Row Level Security. This is how Notion, Linear, and most modern SaaS products scale — one database, strict isolation at the query layer.
┌─────────────────────────────────────────────────────────┐
│                     Next.js App                          │
├──────────────┬──────────────────┬─────────────────────────┤
│  Marketing   │  App (tenant)    │  Admin Panel            │
│  (public)    │  acme.app.com    │  admin.app.com          │
├──────────────┴──────────────────┴─────────────────────────┤
│  Middleware → resolve tenant → check auth → permissions │
├───────────────────────────────────────────────────────────┤
│  Server Actions / Server Components / Route Handlers      │
├───────────────────────────────────────────────────────────┤
│  Supabase (Postgres + Auth + RLS)  │  Stripe Webhooks    │
└───────────────────────────────────────────────────────────┘

1. Project Setup

Start with Next.js 15 App Router, TypeScript, Tailwind, and Supabase. Stripe handles billing. The stack mirrors what production SaaS teams actually ship.

npx create-next-app@latest saas-app --typescript --app --tailwind
cd saas-app
npm install @supabase/supabase-js @supabase/ssr stripe @stripe/stripe-js
npm install -D @types/stripe

Environment variables:

# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

# Stripe
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...

# App
NEXT_PUBLIC_APP_URL=https://app.yourdomain.com
NEXT_PUBLIC_ROOT_DOMAIN=yourdomain.com

Recommended folder structure:

app/
  (marketing)/           # Public pages — landing, pricing
    page.tsx
    pricing/page.tsx
  (auth)/                # Login, register, invite accept
    login/page.tsx
    invite/[token]/page.tsx
  (app)/                 # Tenant-scoped app
    [orgSlug]/
      layout.tsx         # Org context + sidebar
      projects/
        page.tsx
        [projectId]/page.tsx
      settings/
        members/page.tsx
        billing/page.tsx
  (admin)/               # Internal admin panel
    admin/
      organizations/page.tsx
      users/page.tsx
  api/
    webhooks/stripe/route.ts
lib/
  supabase/              # client, server, middleware helpers
  stripe/                # Stripe client + helpers
  permissions/           # Role → permission mapping
  tenant/                # Tenant resolution utilities
middleware.ts
types/
  database.ts            # Generated Supabase types

2. Database Design

The schema is the foundation. Every tenant-scoped table carries an organization_id foreign key. Roles live in a separate table so you can add custom roles later without schema migrations.

Core tables

-- Organizations (tenants)
CREATE TABLE organizations (
  id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name          TEXT NOT NULL,
  slug          TEXT NOT NULL UNIQUE,          -- acme → acme.yourdomain.com
  logo_url      TEXT,
  stripe_customer_id TEXT UNIQUE,
  plan          TEXT NOT NULL DEFAULT 'free',  -- free | pro | enterprise
  created_at    TIMESTAMPTZ DEFAULT now(),
  updated_at    TIMESTAMPTZ DEFAULT now()
);

-- Profiles (extends Supabase auth.users)
CREATE TABLE profiles (
  id            UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  email         TEXT NOT NULL,
  full_name     TEXT,
  avatar_url    TEXT,
  created_at    TIMESTAMPTZ DEFAULT now()
);

-- Memberships (user ↔ organization with role)
CREATE TABLE memberships (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  role            TEXT NOT NULL DEFAULT 'member',  -- owner | admin | member | viewer
  created_at      TIMESTAMPTZ DEFAULT now(),
  UNIQUE (user_id, organization_id)
);

-- Invitations (pending team members)
CREATE TABLE invitations (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  email           TEXT NOT NULL,
  role            TEXT NOT NULL DEFAULT 'member',
  token           TEXT NOT NULL UNIQUE,
  invited_by      UUID NOT NULL REFERENCES profiles(id),
  expires_at      TIMESTAMPTZ NOT NULL,
  accepted_at     TIMESTAMPTZ,
  created_at      TIMESTAMPTZ DEFAULT now()
);

-- Projects (org-scoped resources)
CREATE TABLE projects (
  id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
  name            TEXT NOT NULL,
  description     TEXT,
  status          TEXT NOT NULL DEFAULT 'active',
  created_by      UUID NOT NULL REFERENCES profiles(id),
  created_at      TIMESTAMPTZ DEFAULT now(),
  updated_at      TIMESTAMPTZ DEFAULT now()
);

-- Subscriptions (Stripe sync)
CREATE TABLE subscriptions (
  id                   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  organization_id      UUID NOT NULL UNIQUE REFERENCES organizations(id) ON DELETE CASCADE,
  stripe_subscription_id TEXT UNIQUE,
  stripe_price_id      TEXT,
  status               TEXT NOT NULL DEFAULT 'inactive',
  current_period_end   TIMESTAMPTZ,
  cancel_at_period_end BOOLEAN DEFAULT false,
  created_at           TIMESTAMPTZ DEFAULT now(),
  updated_at           TIMESTAMPTZ DEFAULT now()
);

Indexes for performance

CREATE INDEX idx_memberships_user ON memberships(user_id);
CREATE INDEX idx_memberships_org ON memberships(organization_id);
CREATE INDEX idx_projects_org ON projects(organization_id);
CREATE INDEX idx_invitations_token ON invitations(token);
CREATE INDEX idx_invitations_email ON invitations(email);
CREATE INDEX idx_organizations_slug ON organizations(slug);
Design principle

Keep tenant identity in one place: organizations. Every query filters by organization_id. Never pass org IDs from the client without verifying membership server-side.

3. Row Level Security

RLS is your last line of defense. Even if application code has a bug, Postgres refuses cross-tenant reads and writes. Enable RLS on every tenant-scoped table.

Helper function — get user's org IDs

CREATE OR REPLACE FUNCTION auth.user_org_ids()
RETURNS SETOF UUID AS $$
  SELECT organization_id
  FROM memberships
  WHERE user_id = auth.uid()
$$ LANGUAGE sql STABLE SECURITY DEFINER;

RLS policies

ALTER TABLE organizations ENABLE ROW LEVEL SECURITY;
ALTER TABLE memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;

-- Organizations: members can read their orgs
CREATE POLICY "Members read own orgs"
  ON organizations FOR SELECT
  USING (id IN (SELECT auth.user_org_ids()));

-- Organizations: owners/admins can update
CREATE POLICY "Admins update org"
  ON organizations FOR UPDATE
  USING (
    id IN (
      SELECT organization_id FROM memberships
      WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
    )
  );

-- Projects: members read org projects
CREATE POLICY "Members read projects"
  ON projects FOR SELECT
  USING (organization_id IN (SELECT auth.user_org_ids()));

-- Projects: members+ can insert
CREATE POLICY "Members create projects"
  ON projects FOR INSERT
  WITH CHECK (
    organization_id IN (
      SELECT organization_id FROM memberships
      WHERE user_id = auth.uid() AND role IN ('owner', 'admin', 'member')
    )
  );

-- Invitations: admins manage
CREATE POLICY "Admins manage invitations"
  ON invitations FOR ALL
  USING (
    organization_id IN (
      SELECT organization_id FROM memberships
      WHERE user_id = auth.uid() AND role IN ('owner', 'admin')
    )
  );
Critical: Use the anon key with RLS for all user-facing queries. Reserve the service role key for webhooks, admin panel, and background jobs only — never expose it to the browser.

4. Route Groups

Next.js route groups (folders wrapped in parentheses) let you organize layouts without affecting the URL. Each group gets its own layout, middleware matcher, and auth requirements.

Route groupURL exampleLayout purpose
(marketing)/pricingPublic navbar, no auth
(auth)/loginMinimal auth layout
(app)/acme/projectsOrg sidebar, tenant context
(admin)/admin/organizationsInternal ops dashboard

Org layout (app/(app)/[orgSlug]/layout.tsx)

import { createClient } from "@/lib/supabase/server";
import { redirect, notFound } from "next/navigation";
import { OrgSidebar } from "@/components/OrgSidebar";

export default async function OrgLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ orgSlug: string }>;
}) {
  const { orgSlug } = await params;
  const supabase = await createClient();

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { data: org } = await supabase
    .from("organizations")
    .select("id, name, slug, plan, memberships!inner(role)")
    .eq("slug", orgSlug)
    .eq("memberships.user_id", user.id)
    .single();

  if (!org) notFound();

  return (
    <div className="flex min-h-screen">
      <OrgSidebar org={org} role={org.memberships[0].role} />
      <main className="flex-1 p-6">{children}</main>
    </div>
  );
}

5. Middleware

Middleware runs before every request. It refreshes auth sessions, resolves the current tenant from subdomain or path slug, and redirects unauthenticated users. This is where Notion-style workspace routing begins.

import { NextResponse, type NextRequest } from "next/server";
import { updateSession } from "@/lib/supabase/middleware";

const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN!;
const ADMIN_HOST = `admin.${ROOT_DOMAIN}`;

export async function middleware(request: NextRequest) {
  const url = request.nextUrl;
  const hostname = request.headers.get("host") ?? "";
  const subdomain = hostname.replace(`.${ROOT_DOMAIN}`, "").replace(ROOT_DOMAIN, "");

  // Refresh Supabase session
  let response = await updateSession(request);

  // Admin panel — separate subdomain
  if (hostname.startsWith("admin.")) {
    return handleAdminRoute(request, response);
  }

  // Tenant subdomain: acme.yourdomain.com → rewrite to /acme/...
  if (subdomain && subdomain !== "www" && subdomain !== "app") {
    const path = url.pathname === "/" ? "/projects" : url.pathname;
    return NextResponse.rewrite(
      new URL(`/${subdomain}${path}`, request.url)
    );
  }

  return response;
}

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|api/webhooks|.*\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

For path-based routing (e.g. /acme/projects), skip subdomain rewriting and let the [orgSlug] dynamic segment handle tenant resolution in the layout instead.

6. Dynamic Subdomains

Subdomain routing (acme.yourdomain.com) gives each organization a branded workspace — how Slack and Notion feel. It requires DNS wildcard records and platform configuration.

DNS setup

  • Add a wildcard CNAME: *.yourdomain.com → your-app.vercel.app
  • Add the root domain and admin.yourdomain.com separately
  • On Vercel: add *.yourdomain.com as a domain in project settings

Tenant resolution utility

// lib/tenant/resolve.ts
import { headers } from "next/headers";

export async function getTenantSlug(): Promise<string | null> {
  const host = (await headers()).get("host") ?? "";
  const root = process.env.NEXT_PUBLIC_ROOT_DOMAIN!;

  // Subdomain: acme.yourdomain.com
  if (host.endsWith(`.${root}`) && !host.startsWith("admin.")) {
    return host.replace(`.${root}`, "");
  }

  return null; // Fall back to path-based [orgSlug]
}

export async function getOrgBySlug(slug: string) {
  const supabase = await createClient();
  return supabase
    .from("organizations")
    .select("*")
    .eq("slug", slug)
    .single();
}
Slug validation: Organization slugs must be lowercase, alphanumeric with hyphens, 3–32 characters, and reserved words blocked (admin, api, www, app). Validate on creation and store uniquely in the database.

7. Workspaces & Organizations

When a user signs up, create their profile and first organization in one transaction. The signup flow is the entry point to your multi-tenant system.

Create organization Server Action

"use server";

import { createClient } from "@/lib/supabase/server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";

function slugify(name: string): string {
  return name
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-|-$/g, "")
    .slice(0, 32);
}

export async function createOrganization(formData: FormData) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const name = formData.get("name") as string;
  const slug = slugify(name);

  const { data: org, error } = await supabase
    .from("organizations")
    .insert({ name, slug })
    .select("id, slug")
    .single();

  if (error) return { error: error.message };

  await supabase.from("memberships").insert({
    user_id: user.id,
    organization_id: org.id,
    role: "owner",
  });

  revalidatePath("/");
  redirect(`/${org.slug}/projects`);
}

Organization switcher

Users in multiple orgs need a switcher in the sidebar. Fetch all memberships server-side and render links to each org's workspace. Store the last-active org in a cookie for faster redirects after login.

// components/OrgSwitcher.tsx — Server Component
const { data: memberships } = await supabase
  .from("memberships")
  .select("role, organizations(id, name, slug, logo_url)")
  .eq("user_id", user.id);

// Render dropdown with links to /{org.slug}/projects

8. Roles & Permissions

Roles define what a member can do inside an organization. Keep permissions in code (not the database) for v1 — migrate to a permissions table when customers need custom roles.

// lib/permissions/index.ts
export const ROLES = ["owner", "admin", "member", "viewer"] as const;
export type Role = (typeof ROLES)[number];

export const PERMISSIONS = {
  "org:update":       ["owner", "admin"],
  "org:delete":       ["owner"],
  "member:invite":    ["owner", "admin"],
  "member:remove":    ["owner", "admin"],
  "member:change_role":["owner"],
  "project:create":   ["owner", "admin", "member"],
  "project:delete":   ["owner", "admin"],
  "project:read":     ["owner", "admin", "member", "viewer"],
  "billing:manage":   ["owner"],
} as const;

export type Permission = keyof typeof PERMISSIONS;

export function hasPermission(role: Role, permission: Permission): boolean {
  return (PERMISSIONS[permission] as readonly Role[]).includes(role);
}

export function requirePermission(role: Role, permission: Permission) {
  if (!hasPermission(role, permission)) {
    throw new Error(`Forbidden: requires ${permission}`);
  }
}

Use permissions in Server Actions before any mutation:

export async function deleteProject(projectId: string, orgSlug: string) {
  const { role } = await getMembership(orgSlug);
  requirePermission(role, "project:delete");

  const supabase = await createClient();
  await supabase.from("projects").delete().eq("id", projectId);
  revalidatePath(`/${orgSlug}/projects`);
}
RoleTypical useBilling
OwnerFounder, account creatorFull billing access
AdminTeam lead, IT adminView only
MemberDay-to-day contributorNone
ViewerStakeholder, clientNone

9. Team Invitations

Invitations are the growth loop of every SaaS product. An admin sends an invite, the recipient clicks a link, and they land in the workspace — existing user or new signup.

Send invitation Server Action

"use server";

import { randomBytes } from "crypto";
import { sendInviteEmail } from "@/lib/email";

export async function inviteMember(
  orgId: string,
  email: string,
  role: Role
) {
  const { user, membership } = await getAuthContext(orgId);
  requirePermission(membership.role, "member:invite");

  const token = randomBytes(32).toString("hex");
  const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);

  const supabase = await createClient();
  const { error } = await supabase.from("invitations").insert({
    organization_id: orgId,
    email: email.toLowerCase(),
    role,
    token,
    invited_by: user.id,
    expires_at: expiresAt.toISOString(),
  });

  if (error) return { error: error.message };

  await sendInviteEmail({
    to: email,
    inviteUrl: `${process.env.NEXT_PUBLIC_APP_URL}/invite/${token}`,
    orgName: membership.organization.name,
  });

  return { success: true };
}

Accept invitation

export async function acceptInvitation(token: string) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect(`/login?redirect=/invite/${token}`);

  const { data: invite } = await supabase
    .from("invitations")
    .select("*")
    .eq("token", token)
    .is("accepted_at", null)
    .gt("expires_at", new Date().toISOString())
    .single();

  if (!invite) return { error: "Invalid or expired invitation" };
  if (invite.email !== user.email) {
    return { error: "This invitation was sent to a different email" };
  }

  await supabase.from("memberships").insert({
    user_id: user.id,
    organization_id: invite.organization_id,
    role: invite.role,
  });

  await supabase
    .from("invitations")
    .update({ accepted_at: new Date().toISOString() })
    .eq("id", invite.id);

  const { data: org } = await supabase
    .from("organizations")
    .select("slug")
    .eq("id", invite.organization_id)
    .single();

  redirect(`/${org!.slug}/projects`);
}

10. Projects

Projects are the primary resource inside a workspace. Every project query includes organization_id — never fetch projects without scoping to the current tenant.

// app/(app)/[orgSlug]/projects/page.tsx
export default async function ProjectsPage({
  params,
}: {
  params: Promise<{ orgSlug: string }>;
}) {
  const { orgSlug } = await params;
  const supabase = await createClient();

  const { data: org } = await supabase
    .from("organizations")
    .select("id")
    .eq("slug", orgSlug)
    .single();

  const { data: projects } = await supabase
    .from("projects")
    .select("id, name, status, created_at, profiles(full_name)")
    .eq("organization_id", org!.id)
    .order("created_at", { ascending: false });

  return (
    <div>
      <header className="flex justify-between items-center mb-6">
        <h1>Projects</h1>
        <CreateProjectButton orgSlug={orgSlug} />
      </header>
      <ProjectList projects={projects ?? []} orgSlug={orgSlug} />
    </div>
  );
}

Plan limits gate project creation. Before inserting, check the org's subscription plan and current project count:

const PLAN_LIMITS = { free: 3, pro: 50, enterprise: Infinity };

async function canCreateProject(orgId: string, plan: string) {
  const { count } = await supabase
    .from("projects")
    .select("*", { count: "exact", head: true })
    .eq("organization_id", orgId);

  return (count ?? 0) < PLAN_LIMITS[plan as keyof typeof PLAN_LIMITS];
}

11. Billing, Subscriptions & Stripe

Stripe handles payment processing. Your app stores a reference to the Stripe customer and subscription, then syncs state via webhooks. Never trust client-side payment confirmation.

Create checkout session

"use server";

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function createCheckoutSession(orgId: string, priceId: string) {
  const { membership, org } = await getAuthContext(orgId);
  requirePermission(membership.role, "billing:manage");

  let customerId = org.stripe_customer_id;

  if (!customerId) {
    const customer = await stripe.customers.create({
      email: membership.user.email,
      metadata: { organization_id: orgId },
    });
    customerId = customer.id;
    await supabase
      .from("organizations")
      .update({ stripe_customer_id: customerId })
      .eq("id", orgId);
  }

  const session = await stripe.checkout.sessions.create({
    customer: customerId,
    mode: "subscription",
    line_items: [{ price: priceId, quantity: 1 }],
    success_url: `${process.env.NEXT_PUBLIC_APP_URL}/${org.slug}/settings/billing?success=1`,
    cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/${org.slug}/settings/billing`,
    metadata: { organization_id: orgId },
  });

  redirect(session.url!);
}

Stripe webhook handler

// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(request: Request) {
  const body = await request.text();
  const sig = (await headers()).get("stripe-signature")!;

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  const supabase = createServiceClient(); // service role — bypasses RLS

  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      const orgId = session.metadata!.organization_id!;
      const subscription = await stripe.subscriptions.retrieve(
        session.subscription as string
      );
      await supabase.from("subscriptions").upsert({
        organization_id: orgId,
        stripe_subscription_id: subscription.id,
        stripe_price_id: subscription.items.data[0].price.id,
        status: subscription.status,
        current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
      });
      await supabase
        .from("organizations")
        .update({ plan: "pro" })
        .eq("id", orgId);
      break;
    }
    case "customer.subscription.updated":
    case "customer.subscription.deleted": {
      const sub = event.data.object as Stripe.Subscription;
      const status = sub.status === "active" ? "pro" : "free";
      await supabase.from("subscriptions").update({
        status: sub.status,
        cancel_at_period_end: sub.cancel_at_period_end,
        current_period_end: new Date(sub.current_period_end * 1000).toISOString(),
      }).eq("stripe_subscription_id", sub.id);
      // Look up org and downgrade plan if cancelled
      break;
    }
  }

  return new Response("OK", { status: 200 });
}
Webhook security: Always verify the Stripe signature. Use the service role Supabase client in webhooks. Register the webhook URL in Stripe Dashboard: https://yourdomain.com/api/webhooks/stripe.

12. Admin Panel

Your internal team needs tools to manage customers without SQL access. The admin panel lives on a separate subdomain (admin.yourdomain.com) with its own auth gate — only users with an is_super_admin flag on their profile can access it.

Organizations

  • Search, view, suspend orgs
  • Override plan limits
  • View member list

Users

  • Search by email
  • View org memberships
  • Force password reset

Billing

  • Subscription status
  • Manual plan changes
  • Refund triggers

Audit log

  • Every admin action logged
  • Actor, target, timestamp
  • Immutable append-only
// Admin uses service role client — NEVER expose to browser
import { createServiceClient } from "@/lib/supabase/admin";

export async function suspendOrganization(orgId: string, reason: string) {
  await requireSuperAdmin();
  const supabase = createServiceClient();

  await supabase
    .from("organizations")
    .update({ status: "suspended" })
    .eq("id", orgId);

  await supabase.from("admin_audit_log").insert({
    action: "org.suspend",
    target_id: orgId,
    reason,
    actor_id: (await getAdminUser()).id,
  });
}

13. Server Actions Pattern

All mutations — create org, invite member, create project, manage billing — go through Server Actions. They run on the server, validate permissions, and revalidate cached pages.

// lib/actions/context.ts — shared auth helper
export async function getAuthContext(orgSlug: string) {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) redirect("/login");

  const { data: membership } = await supabase
    .from("memberships")
    .select("role, organizations(id, name, slug, plan, stripe_customer_id)")
    .eq("organizations.slug", orgSlug)
    .eq("user_id", user.id)
    .single();

  if (!membership) throw new Error("Not a member of this organization");

  return {
    user,
    role: membership.role as Role,
    org: membership.organizations,
    membership,
  };
}

// Every action follows this pattern:
// 1. getAuthContext(orgSlug)
// 2. requirePermission(role, "...")
// 3. Validate input (zod)
// 4. Mutate via Supabase (RLS as backup)
// 5. revalidatePath(...)
// 6. Return result or redirect

14. Tenant Isolation

Tenant isolation is enforced at three layers. If any one layer fails, the others still protect customer data.

Layer 1: Application

Every Server Action and query includes organization_id from verified membership — never from client input alone.

Layer 2: Middleware

Tenant slug resolved server-side. Unauthenticated or non-member requests redirected before page render.

Layer 3: RLS

Postgres policies reject any query that crosses tenant boundaries, even if application code has a bug.

Isolation checklist

  • Never use org ID from URL params without membership check
  • Service role key only in webhooks and admin — never in client bundle
  • Stripe metadata includes organization_id for webhook routing
  • File storage paths prefixed with {org_id}/
  • Background jobs scoped to single org per job
  • Audit log on every cross-tenant admin action

15. Production Deployment

Multi-tenant SaaS has deployment requirements beyond a standard Next.js app. Plan for wildcard domains, webhook endpoints, and environment separation.

Vercel configuration

// vercel.json
{
  "rewrites": [
    {
      "source": "/:path*",
      "has": [{ "type": "host", "value": "(?<subdomain>.*)\.yourdomain\.com" }],
      "destination": "/:subdomain/:path*"
    }
  ]
}

Production checklist

  • Wildcard DNS and SSL for *.yourdomain.com
  • Stripe webhook registered for production URL
  • Supabase RLS enabled and tested on all tables
  • Service role key in server-only env vars
  • Email provider configured (Resend, SendGrid) for invitations
  • Separate Supabase projects for staging and production
  • Rate limiting on invitation and signup endpoints
  • Error monitoring (Sentry) with org context tags
Render alternative

On Render, configure custom domains with wildcard support and set NEXT_PUBLIC_ROOT_DOMAIN in environment variables. Stripe webhooks and Supabase connections work identically to Vercel.

16. How Top SaaS Apps Structure Multi-Tenancy

The architecture in this guide mirrors patterns used by industry-leading products. Understanding their models helps you make better design decisions.

ProductTenant modelKey pattern
NotionWorkspacePages nested under workspace; guest access via email invite
LinearOrganizationTeams within org; strict role hierarchy; subdomain per org
SlackWorkspaceSubdomain routing (acme.slack.com); channels scoped to workspace
ClickUpWorkspaceSpaces → Folders → Lists hierarchy; plan limits per workspace
TrelloOrganizationBoards belong to orgs; free vs paid at org level

Common patterns across all of them:

  • Organization is the billing entity — subscriptions attach to the workspace, not individual users
  • Roles are org-scoped — being an admin in one workspace doesn't grant admin in another
  • Invitations are email-based — with expiring tokens and role assignment at invite time
  • Resources never cross tenants — enforced at DB, API, and UI layers
  • Plan limits gate features — project count, seats, storage checked server-side before mutations

17. Common Pitfalls

1. Trusting client-supplied org IDs

Never accept organizationId from form data without verifying the current user is a member. Always resolve org from slug + membership check.

2. Skipping RLS because "we check in code"

Application bugs happen. RLS is insurance. Enable it on every tenant table from day one — retrofitting RLS on a live database is painful.

3. Billing state only in Stripe

Cache subscription status in your database via webhooks. Rendering the billing page should not require a Stripe API call on every page load.

4. No plan enforcement server-side

Client-side "upgrade" banners are fine, but project creation, seat limits, and feature gates must be enforced in Server Actions — not just hidden in the UI.

5. Subdomain routing without reserved slugs

Block slugs like admin, api, www, app, and billing or your routing will break.

6. Orphaned Stripe customers

Always store stripe_customer_id on the organization when checkout starts. Create the customer before the checkout session, not after.

Need help building your multi-tenant SaaS?

We architect and ship SaaS platforms for startups and enterprises — from database design and Stripe integration to admin panels and production deployment. Share your product vision and we'll help you build it right the first time.

Request SaaS Architecture Consultation

Shares
Get Quote
Let's build something powerful

Have a project idea? Let’s turn it into a scalable product.

Book Free Consultation

© 2026 Endurance Softwares. All rights reserved.