How to Build a Multi-Tenant SaaS App (2026)
Multi-tenancy lets multiple customers (tenants) share one application while keeping their data isolated. Every SaaS with team/organization features is multi-tenant. Here's how to implement it.
The Three Patterns
1. Shared Database + Tenant Column (Recommended)
All tenants share one database. Every table has a tenant_id column.
CREATE TABLE projects (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
Pros: Simplest. Cheapest. Easy to maintain. Cons: Must always filter by tenant_id. Data leak risk if you forget.
2. Schema-Per-Tenant (PostgreSQL)
Each tenant gets their own PostgreSQL schema within one database.
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.projects (...);
CREATE SCHEMA tenant_globex;
CREATE TABLE tenant_globex.projects (...);
Pros: Better isolation. No tenant_id on every query. Cons: Migration complexity. Schema count limits.
3. Database-Per-Tenant
Each tenant gets their own database.
tenant-acme.db
tenant-globex.db
tenant-initech.db
Pros: Maximum isolation. Easy to delete tenant data. Cons: Most expensive. Connection management complexity. Migrations across all databases.
Recommended: Shared Database + Tenant Column
For 95% of SaaS apps, shared database with tenant_id is the right choice. Here's the complete implementation.
Database Schema (Drizzle)
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
plan: text('plan').default('free').notNull(),
createdAt: timestamp('created_at').defaultNow(),
})
export const members = pgTable('members', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
userId: text('user_id').notNull(), // Clerk user ID
role: text('role').default('member').notNull(), // admin, member
})
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').references(() => tenants.id).notNull(),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
})
Tenant Context Middleware
// lib/tenant.ts
import { auth } from '@clerk/nextjs/server'
export async function getTenant() {
const { userId, orgId } = auth()
if (!userId) throw new Error('Unauthorized')
// Clerk orgId maps to your tenant
const tenant = await db.query.tenants.findFirst({
where: eq(tenants.clerkOrgId, orgId),
})
if (!tenant) throw new Error('Tenant not found')
return tenant
}
Always Filter by Tenant
// ✅ Correct: always include tenantId
export async function getProjects() {
const tenant = await getTenant()
return db.select()
.from(projects)
.where(eq(projects.tenantId, tenant.id))
}
// ❌ WRONG: missing tenant filter = data leak
export async function getProjects() {
return db.select().from(projects) // Returns ALL tenants' data!
}
Row-Level Security (Extra Safety)
-- PostgreSQL RLS as a safety net
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.tenant_id')::uuid);
Set the tenant context at the start of each request:
await db.execute(sql`SET LOCAL app.tenant_id = ${tenant.id}`)
Even if you forget a WHERE clause, RLS prevents data leaks.
Clerk Organizations Integration
// When user creates an org in Clerk → create tenant
// Clerk webhook handler
if (event.type === 'organization.created') {
await db.insert(tenants).values({
name: event.data.name,
slug: event.data.slug,
clerkOrgId: event.data.id,
})
}
// When member joins org → add to members table
if (event.type === 'organizationMembership.created') {
await db.insert(members).values({
tenantId: tenant.id,
userId: event.data.public_user_data.user_id,
role: event.data.role,
})
}
Common Patterns
Tenant-Scoped API Routes
// Middleware that injects tenant into every request
export async function middleware(request: NextRequest) {
const tenant = await getTenantFromRequest(request)
request.headers.set('x-tenant-id', tenant.id)
}
Tenant Subdomains
acme.yourapp.com → tenant: acme
globex.yourapp.com → tenant: globex
function getTenantFromHostname(hostname: string) {
const subdomain = hostname.split('.')[0]
return db.query.tenants.findFirst({
where: eq(tenants.slug, subdomain),
})
}
Plan-Based Feature Gating
export async function canUseFeature(feature: string) {
const tenant = await getTenant()
const limits = PLAN_LIMITS[tenant.plan]
return limits.features.includes(feature)
}
FAQ
Which pattern should I use?
Shared database + tenant_id for 95% of apps. Database-per-tenant only for strict compliance requirements or if tenants need independent backups.
How do I prevent data leaks between tenants?
Always filter by tenant_id. Add PostgreSQL RLS as a safety net. Write tests that verify tenant isolation.
Should I use Clerk Organizations?
Yes, if using Clerk. It handles team creation, invitations, roles, and the org switcher UI. Map Clerk org IDs to your tenant table.
How do I handle migrations with database-per-tenant?
Script that runs migrations across all databases. Tools like Turso support this pattern well with their multi-database feature.
Bottom Line
Use shared database + tenant_id column. It's the simplest, cheapest, and most maintainable pattern. Add PostgreSQL RLS for safety. Use Clerk Organizations for team management. Test tenant isolation in your CI pipeline. Multi-tenancy isn't as hard as it sounds — it's just adding WHERE tenant_id = ? to every query.