Skip to main content

Multi-Tenancy Patterns: Shared vs Isolated Databases in SaaS

·StarterPick Team
multi-tenancypostgresqlsaasarchitecture2026

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

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

BoilerplateMulti-TenancyPattern
SupastarterSupabase RLS
MakerkitSupabase RLS + Organizations
VolcaPrisma + organizationId
BedrockEnterprise 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.


Find multi-tenant SaaS boilerplates on StarterPick.

Check out this boilerplate

View Supastarter on StarterPick →

Comments