Multi-Tenancy Architecture Models
Multi-tenant SaaS architecture comes in three variants, each with different tradeoffs between isolation strength, cost, and operational complexity.
Shared database, shared schema (most common): All tenants share a single database and all tables have a tenant_id or organization_id column to separate their data. This is the most cost-efficient approach and what this guide covers. The isolation risk is that a bug in your query layer could return one tenant's data to another — mitigated with row-level security at the database level.
Shared database, separate schema: Each tenant gets their own PostgreSQL schema (essentially a namespace) within a shared database instance. Stronger isolation, more complex migrations, harder to operate at large scale.
Separate database per tenant: Maximum isolation, useful for regulated industries (healthcare, finance) where data residency requirements mandate physical separation. Operationally complex and expensive — not recommended unless isolation requirements specifically demand it.
For most SaaS products, shared database with row-level security is the right starting point. This is the model I use in SaaS MVP builds and what the rest of this guide covers.
Database Schema for Multi-Tenancy
The schema foundation needs to be right from day one. Adding organization context to tables later is painful — do it in the initial schema.
-- Core multi-tenant tables
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
slug VARCHAR(100) UNIQUE NOT NULL, -- used for subdomain routing
stripe_customer_id VARCHAR(255),
stripe_subscription_id VARCHAR(255),
subscription_status VARCHAR(50) DEFAULT 'trialing',
subscription_plan VARCHAR(50) DEFAULT 'free',
trial_ends_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE organization_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL DEFAULT 'member', -- owner | admin | member
invited_email VARCHAR(255),
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(organization_id, user_id)
);
-- Example: a resource belonging to a tenant
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Row Level Security: users can only see their organization's data
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY "org_members_can_view_projects" ON projects
FOR SELECT USING (
organization_id IN (
SELECT organization_id FROM organization_members
WHERE user_id = auth.uid()
)
);
CREATE POLICY "org_admins_can_manage_projects" ON projects
FOR ALL USING (
organization_id IN (
SELECT organization_id FROM organization_members
WHERE user_id = auth.uid()
AND role IN ('owner', 'admin')
)
);
Tenant Isolation in Next.js
Every request in a multi-tenant Next.js application needs to resolve which tenant is being served. The two common approaches: subdomain routing (tenant.yoursaas.com) and path-based routing (yoursaas.com/org/tenant). I use subdomain routing for the cleaner UX.
// middleware.ts — resolves tenant from subdomain
import { NextRequest, NextResponse } from "next/server";
import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs";
export async function middleware(req: NextRequest) {
const hostname = req.headers.get("host") || "";
const currentHost = hostname.replace(".yoursaas.com", "").replace("www.", "");
// Exclude root domain, www, and special subdomains
const isRootDomain = hostname === "yoursaas.com" || hostname === "www.yoursaas.com";
const isSpecialSubdomain = ["app", "api", "docs", "admin"].includes(currentHost);
if (isRootDomain || isSpecialSubdomain) {
return NextResponse.next();
}
// Set tenant context in request headers for downstream use
const res = NextResponse.next();
res.headers.set("x-tenant-slug", currentHost);
// Verify tenant exists and user has access
const supabase = createMiddlewareClient({ req, res });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
return NextResponse.redirect(new URL("/login", req.url));
}
const { data: org } = await supabase
.from("organizations")
.select("id, subscription_status")
.eq("slug", currentHost)
.single();
if (!org) {
return NextResponse.redirect(new URL("/not-found", req.url));
}
// Check if user is a member of this organization
const { data: membership } = await supabase
.from("organization_members")
.select("role")
.eq("organization_id", org.id)
.eq("user_id", session.user.id)
.single();
if (!membership) {
return NextResponse.redirect(new URL("/unauthorized", req.url));
}
res.headers.set("x-tenant-id", org.id);
res.headers.set("x-tenant-role", membership.role);
return res;
}
Stripe Billing Integration
Multi-tenant SaaS billing requires linking Stripe customers to organizations, not to individual users. Each organization has one Stripe customer, and subscription status is stored on the organization record.
// lib/stripe.ts — Stripe setup and helpers
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-11-20",
typescript: true,
});
export async function createOrGetStripeCustomer(
orgId: string,
orgName: string,
adminEmail: string
): Promise<string> {
// Check if org already has a Stripe customer
const { data: org } = await supabase
.from("organizations")
.select("stripe_customer_id")
.eq("id", orgId)
.single();
if (org?.stripe_customer_id) {
return org.stripe_customer_id;
}
// Create new Stripe customer
const customer = await stripe.customers.create({
name: orgName,
email: adminEmail,
metadata: { organization_id: orgId },
});
// Store customer ID on organization
await supabase
.from("organizations")
.update({ stripe_customer_id: customer.id })
.eq("id", orgId);
return customer.id;
}
// Checkout session for subscription upgrade
export async function createCheckoutSession(
orgId: string,
priceId: string,
returnUrl: string
): Promise<string> {
const { data: org } = await supabase
.from("organizations")
.select("stripe_customer_id, slug")
.eq("id", orgId)
.single();
const session = await stripe.checkout.sessions.create({
customer: org!.stripe_customer_id!,
mode: "subscription",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${returnUrl}?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${returnUrl}?canceled=true`,
subscription_data: {
metadata: { organization_id: orgId },
trial_period_days: 14,
},
metadata: { organization_id: orgId },
});
return session.url!;
}
Role-Based Access Control
Multi-tenant SaaS needs RBAC at two levels: organization-level roles (owner, admin, member) that control what a user can do within their organization, and subscription-level gates that control which features are available based on the organization's plan.
// lib/permissions.ts
type OrgRole = "owner" | "admin" | "member";
type Feature = "advanced_analytics" | "api_access" | "custom_domain" | "unlimited_projects";
type Plan = "free" | "starter" | "pro" | "enterprise";
const PLAN_FEATURES: Record<Plan, Feature[]> = {
free: [],
starter: ["advanced_analytics"],
pro: ["advanced_analytics", "api_access", "unlimited_projects"],
enterprise: ["advanced_analytics", "api_access", "unlimited_projects", "custom_domain"],
};
const ROLE_PERMISSIONS = {
owner: ["manage_billing", "manage_members", "delete_organization", "manage_projects", "view_analytics"],
admin: ["manage_members", "manage_projects", "view_analytics"],
member: ["manage_projects", "view_analytics"],
} as const;
export function canAccess(role: OrgRole, permission: string): boolean {
return ROLE_PERMISSIONS[role].includes(permission as never);
}
export function hasFeature(plan: Plan, feature: Feature): boolean {
return PLAN_FEATURES[plan].includes(feature);
}
// React hook for permission checking
export function usePermissions(orgRole: OrgRole, orgPlan: Plan) {
return {
can: (permission: string) => canAccess(orgRole, permission),
hasFeature: (feature: Feature) => hasFeature(orgPlan, feature),
isOwner: orgRole === "owner",
isAdmin: orgRole === "owner" || orgRole === "admin",
};
}
Production Deployment Architecture
Subdomain-based multi-tenant SaaS requires wildcard DNS and wildcard SSL configuration at the infrastructure level. Here is the setup I use:
DNS: Wildcard CNAME record: *.yoursaas.com → yoursaas.com. This routes all subdomain traffic to your application server without needing individual DNS records per tenant.
Vercel deployment: Add both yoursaas.com and *.yoursaas.com as domains in your Vercel project settings. Vercel automatically provisions wildcard SSL certificates via Let's Encrypt. Update your Next.js config:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
],
},
];
},
};
Database: Use Supabase (PostgreSQL + Auth + Row Level Security built in) or Neon for serverless PostgreSQL. Both work excellently with Vercel Edge functions. Enable connection pooling (PgBouncer in Supabase) to handle concurrent requests efficiently.
This architecture scales from your first customer to thousands without infrastructure changes. The Vercel + Supabase combination has no scaling step to worry about — both scale automatically based on usage. For bespoke multi-tenant SaaS builds, see my SaaS MVP service and check the project portfolio for delivered examples.