TL;DR
Shared database with row-level security works for 95% of SaaS products — simplest to implement, easiest to maintain, scales to millions of tenants. Schema-per-tenant for regulated industries (healthcare, finance) where tenant data isolation is a compliance requirement. Database-per-tenant only for enterprise clients paying $10k+/month who require contractual data isolation. Start with RLS.
The Three Patterns
Pattern 1: Shared Database (Row-Level Security)
One database → One schema → Many tenants
Tenant rows separated by organization_id
Isolation enforced by RLS policies
Pattern 2: Schema-Per-Tenant
One database → Many schemas (one per tenant)
public, tenant_abc, tenant_xyz
Tables duplicated per schema
Pattern 3: Database-Per-Tenant
Many databases (one per tenant)
Complete isolation at the infrastructure level
Pattern 1: Shared Database with RLS (Recommended)
Row-Level Security is a PostgreSQL feature that enforces access control at the database level. Even if application code has a bug and passes the wrong organization_id, the database enforces the policy.
Schema Design
// prisma/schema.prisma
model Organization {
id String @id @default(cuid())
name String
plan String @default("free")
members Member[]
projects Project[]
createdAt DateTime @default(now())
}
model Member {
id String @id @default(cuid())
userId String
organizationId String
role MemberRole @default(MEMBER)
organization Organization @relation(fields: [organizationId], references: [id])
@@unique([userId, organizationId])
@@index([organizationId])
}
model Project {
id String @id @default(cuid())
name String
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
tasks Task[]
@@index([organizationId]) // Essential for performance
}
RLS Policies in PostgreSQL
-- Enable RLS on tenant tables
ALTER TABLE "Project" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Task" ENABLE ROW LEVEL SECURITY;
-- Policy: users can only see their organization's data
-- current_setting() reads the org ID set by the application
CREATE POLICY "tenant_isolation" ON "Project"
USING (
"organizationId" IN (
SELECT "organizationId"
FROM "Member"
WHERE "userId" = current_setting('app.current_user_id')
)
);
// lib/db.ts — set tenant context before every query
import { prisma } from './prisma';
export async function withTenant<T>(
userId: string,
organizationId: string,
callback: () => Promise<T>
): Promise<T> {
return prisma.$transaction(async (tx) => {
// Set the current user/org context for RLS
await tx.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
await tx.$executeRaw`SELECT set_config('app.current_org_id', ${organizationId}, true)`;
return callback();
});
}
// Usage in API routes — tenant context is automatic
export async function GET(req: Request) {
const { userId, organizationId } = await getAuthContext(req);
return withTenant(userId, organizationId, async () => {
// RLS ensures this only returns this org's projects
const projects = await prisma.project.findMany();
return Response.json(projects);
});
}
Supabase RLS (The Easy Way)
Supabase makes RLS configuration through their dashboard:
-- Supabase RLS policy using auth.uid()
CREATE POLICY "Users can view org projects"
ON projects FOR SELECT
USING (
organization_id IN (
SELECT organization_id FROM members
WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can insert org projects"
ON projects FOR INSERT
WITH CHECK (
organization_id IN (
SELECT organization_id FROM members
WHERE user_id = auth.uid() AND role IN ('owner', 'admin', 'member')
)
);
Pattern 2: Schema-Per-Tenant
Each tenant gets their own PostgreSQL schema:
database: myapp
├── schema: public (shared: auth, billing)
├── schema: org_abc123 (tenant A)
│ ├── projects
│ ├── tasks
│ └── settings
└── schema: org_xyz789 (tenant B)
├── projects
├── tasks
└── settings
// Create tenant schema on org creation
async function provisionTenant(organizationId: string) {
const schemaName = `org_${organizationId}`;
await prisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${schemaName}`;
// Run migrations in the new schema
await prisma.$executeRaw`
CREATE TABLE IF NOT EXISTS ${schemaName}.projects (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
)
`;
}
// Query tenant schema
async function getProjects(organizationId: string) {
const schema = `org_${organizationId}`;
return prisma.$queryRaw`
SELECT * FROM ${schema}.projects ORDER BY created_at DESC
`;
}
When to use: Regulatory compliance (ISO 27001, SOC 2 Type II), healthcare (HIPAA), or financial products where tenants need contractual schema isolation.
Trade-offs: Schema migrations must run per-tenant (automated, but operationally complex at hundreds of tenants).
Pattern 3: Database-Per-Tenant
Each tenant gets their own database:
// Tenant registry
const tenantDatabases = new Map<string, PrismaClient>();
async function getTenantDb(organizationId: string): Promise<PrismaClient> {
if (tenantDatabases.has(organizationId)) {
return tenantDatabases.get(organizationId)!;
}
const tenant = await getTenantConfig(organizationId);
const db = new PrismaClient({
datasources: { db: { url: tenant.databaseUrl } }
});
tenantDatabases.set(organizationId, db);
return db;
}
// Usage
export async function GET(req: Request) {
const { organizationId } = await getOrgContext(req);
const db = await getTenantDb(organizationId);
const projects = await db.project.findMany();
return Response.json(projects);
}
When to use: Enterprise clients ($10k+/month) who require contractual data isolation, data residency in specific regions, or custom backup schedules.
Trade-offs: Expensive, complex to operate. Neon's branching can help — each tenant gets a Neon branch/project.
Boilerplate Multi-Tenancy Support
| Boilerplate | Multi-Tenancy | Pattern |
|---|---|---|
| Supastarter | ✅ | Supabase RLS |
| Makerkit | ✅ | Supabase RLS + Organizations |
| Volca | ✅ | Prisma + organizationId |
| Bedrock | ✅ | Enterprise patterns |
| T3 Stack | ❌ (add yourself) | — |
| ShipFast | ❌ | — |
Starting Multi-Tenant from Day One
If you know your SaaS will be multi-tenant, design it in from the start:
// The minimal multi-tenant schema
// Add organizationId to every user-owned resource
model Project {
id String @id @default(cuid())
// ...other fields...
organizationId String // Foreign key to Organization
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
@@index([organizationId]) // Always index this for performance
}
Adding multi-tenancy to a single-tenant app later is painful. If there's any chance of it, add organizationId now.
Organization Invitation Flows
Multi-tenant SaaS requires more than data isolation — users need to invite colleagues into their organization. The typical invite flow: owner sends an email with a unique token, invitee clicks the link, creates (or logs into) an account, and joins the organization.
// app/api/invitations/route.ts — send invitation
export async function POST(req: Request) {
const { email, role } = await req.json();
const { organizationId } = await getOrgContext(req);
// Check if user already a member
const existing = await db.member.findFirst({
where: { organizationId, user: { email } },
});
if (existing) return Response.json({ error: 'Already a member' }, { status: 400 });
// Create invitation token
const invitation = await db.invitation.create({
data: {
email,
role,
organizationId,
token: crypto.randomUUID(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
// Send invitation email
await resend.emails.send({
from: 'noreply@yourapp.com',
to: email,
subject: 'You have been invited to join...',
react: InvitationEmail({ token: invitation.token }),
});
return Response.json({ sent: true });
}
// app/api/invitations/accept/route.ts — accept invitation
export async function POST(req: Request) {
const { token } = await req.json();
const session = await auth();
const invitation = await db.invitation.findUnique({
where: { token },
include: { organization: true },
});
if (!invitation || invitation.expiresAt < new Date()) {
return Response.json({ error: 'Invalid or expired invitation' }, { status: 400 });
}
await db.$transaction([
db.member.create({
data: {
userId: session.user.id,
organizationId: invitation.organizationId,
role: invitation.role,
},
}),
db.invitation.delete({ where: { token } }),
]);
return Response.json({ joined: true, organizationId: invitation.organizationId });
}
The invitation table needs a unique token index and expiry timestamp. Expired invitations should be cleaned up periodically (a cron job or background task).
Multi-Tenancy and Stripe: Per-Organization Billing
In a multi-tenant SaaS, billing typically attaches to the organization, not the individual user. Each organization has a Stripe customer, and users within the organization inherit the subscription features.
model Organization {
id String @id @default(cuid())
name String
stripeCustomerId String? @unique
subscriptionId String?
subscriptionStatus String? @default("free")
plan String @default("free")
// ...
}
// Create Stripe customer when org is created
async function createOrganization(name: string, userId: string) {
const org = await db.organization.create({
data: { name },
});
// Create Stripe customer for the org
const customer = await stripe.customers.create({
name: org.name,
metadata: { organizationId: org.id },
});
await db.organization.update({
where: { id: org.id },
data: { stripeCustomerId: customer.id },
});
// Add the creator as owner
await db.member.create({
data: { userId, organizationId: org.id, role: 'OWNER' },
});
return org;
}
The Stripe webhook handler then updates the organization's subscriptionStatus and plan fields when subscription events fire. Route protection checks the organization's plan — not the individual user's.
Role-Based Access Control in Multi-Tenant SaaS
Most multi-tenant SaaS applications need at least three roles: Owner (billing, all settings), Admin (manage members, most settings), and Member (use the product). Implementing this cleanly:
// lib/permissions.ts
const PERMISSIONS = {
'manage:billing': ['OWNER'],
'manage:members': ['OWNER', 'ADMIN'],
'invite:members': ['OWNER', 'ADMIN'],
'delete:organization': ['OWNER'],
'view:projects': ['OWNER', 'ADMIN', 'MEMBER'],
'create:projects': ['OWNER', 'ADMIN', 'MEMBER'],
'delete:projects': ['OWNER', 'ADMIN'],
} as const;
type Permission = keyof typeof PERMISSIONS;
type MemberRole = 'OWNER' | 'ADMIN' | 'MEMBER';
export function hasPermission(role: MemberRole, permission: Permission): boolean {
return (PERMISSIONS[permission] as readonly string[]).includes(role);
}
// middleware.ts — protect routes by permission
export async function requirePermission(permission: Permission) {
const { member } = await getOrgContext();
if (!hasPermission(member.role, permission)) {
throw new Error('Insufficient permissions');
}
}
The permission system is simple to start and easy to extend. Avoid building a fully dynamic permissions system (RBAC with custom roles per organization) until customers actually request it — the implementation complexity is significant and most SaaS products never need it.
Performance at Scale: The N+1 Trap
The most common multi-tenant performance mistake is running unindexed queries that become N+1 problems as the tenant count grows.
// ❌ Missing the organizationId index — queries slow at scale
model Project {
id String @id @default(cuid())
organizationId String // No index!
}
// Each findMany by organizationId does a full table scan
// ✅ Always index tenant isolation columns
model Project {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
@@index([organizationId]) // Essential
@@index([organizationId, createdAt]) // If you sort by date within org
}
With 100 tenants, unindexed queries are fast. With 10,000 tenants and 1M rows, an unindexed organizationId query does a sequential scan of the entire table — 100ms becomes 10 seconds. Add the index before you need it.
Supabase RLS vs Application-Level Isolation: The Real Trade-off
The debate between Supabase RLS and application-level organizationId filters isn't purely technical — it's about where you want the correctness guarantee to live.
Application-level filtering means your application code is responsible for adding WHERE organization_id = ? to every query. This works correctly when implemented carefully, but has one failure mode: a developer forgets the filter on a new query, and one tenant accidentally sees another's data. This bug may not be obvious in development and may take days or weeks to notice in production.
Supabase RLS moves the enforcement to the database. RLS policies run on every query — there's no way to write a Supabase query that bypasses them without explicitly disabling RLS on a table. A developer writing a query that forgets the tenant filter gets empty results (the database returns only the rows the current user is allowed to see) rather than another tenant's data.
The trade-off is complexity. Supabase RLS requires understanding PostgreSQL policies, which use a SQL-like syntax that not all developers are familiar with. Debugging RLS policy failures can be confusing — the query "succeeds" but returns no rows. Application-level filtering is easier to understand, debug, and audit.
In practice: Supabase-based boilerplates (Supastarter, Makerkit with Supabase adapter) use RLS because it's the natural pattern for the platform. Non-Supabase boilerplates (T3 Stack, ShipFast, most Prisma-based starters) use application-level filtering. Both approaches work; RLS provides a stronger guarantee for teams building regulated-industry products.
Multi-Tenancy Checklist for New SaaS Projects
Before shipping a multi-tenant SaaS to production, verify these implementation details:
Data isolation:
- Every user-owned resource has an
organizationIdforeign key - Every query that returns user data filters by
organizationId - RLS is enabled (if using Supabase) or application filtering is consistent (if not)
- Audit log: can you query what data a specific tenant owns?
Authorization:
- Organization membership is checked before accessing org resources
- Role permissions are enforced consistently (OWNER vs ADMIN vs MEMBER)
- Users can't escalate their own role
Billing:
- Billing attaches to the organization, not individual users
- Feature access gates check the organization's plan
- Subscription status change (cancellation, payment failure) correctly revokes features
Operational:
- You can look up all resources belonging to a specific tenant in the admin panel
- Tenant data can be exported (GDPR/CCPA requirements)
- Tenant data can be deleted on request
This checklist is a starting point, not a ceiling. For regulated industries (healthcare, finance), additional requirements around data residency, audit trails, and encryption at rest apply beyond this baseline.
Choosing the Right Pattern for Your SaaS
The three multi-tenancy patterns aren't equally applicable to all products. Here's how to think about the choice in concrete business terms.
Shared database with row-level security is correct for the vast majority of B2B SaaS products. The performance scales to millions of rows per tenant with proper indexing. The operational simplicity is significant — one database to back up, one to scale, one connection pool to manage. RLS provides strong-enough isolation for most compliance requirements (SOC 2 Type II auditors routinely accept row-level security as sufficient data isolation). The only reasons to move away from this pattern are explicit compliance requirements (HIPAA BAA, specific enterprise contractual requirements for schema isolation) or architectural needs like per-tenant data residency in different geographic regions.
Schema-per-tenant addresses a specific compliance scenario: organizations that require contractual guarantees that their data is physically separated from other tenants' data. ISO 27001 and SOC 2 Type II with strict enterprise requirements sometimes specify this. The operational cost is real: each tenant needs schema migrations when you change your data model. Tooling for running migrations across hundreds of schemas exists (you write a script that iterates over tenants), but it adds complexity to your deployment pipeline. Start here only if you have a concrete compliance requirement, not as a precaution.
Database-per-tenant is the right choice for enterprise products where individual tenants pay enough to justify dedicated infrastructure. At $10k+/month per customer, dedicated infrastructure represents a tiny fraction of their contract value while providing the isolation guarantees that enterprise procurement teams require. At $10k+/month per customer, a dedicated database instance ($50-$200/month for most products) is a reasonable cost. It also enables database-level features you can't get with shared infrastructure: tenant-specific backup schedules, per-tenant encryption keys, data residency in specific cloud regions, and performance isolation (one tenant's heavy queries can't affect others). Neon's branching feature makes this more operationally manageable — each tenant is a Neon project with its own connection string. The infrastructure overhead per tenant is minimal, and tenant onboarding can be automated: when a new enterprise contract is signed, a provisioning script creates the Neon project, runs migrations, and stores the connection string in your tenant registry.
The three patterns aren't mutually exclusive at the product level — many enterprise SaaS products use shared-database with RLS for the majority of their customers, offer schema-per-tenant as an upgrade for mid-market customers who need contractual isolation, and reserve database-per-tenant for their largest enterprise contracts. Starting with RLS and designing the data model to accommodate schema migration later is the pragmatic path for products that anticipate eventual enterprise customer requirements without knowing their timeline.
Compare multi-tenant boilerplates including Supastarter and Makerkit in the StarterPick directory.
See our guide to full-stack TypeScript boilerplates for the database and ORM patterns used with multi-tenancy.
Review Supastarter — the most complete multi-tenant Next.js boilerplate with Supabase RLS.
See best boilerplates for multi-tenant SaaS for a comparison of Makerkit, Supastarter, Bedrock, and Nextacular.
Browse how to convert single-tenant to multi-tenant if you're retrofitting these patterns onto an existing product.