How to Build a Multi-Tenant SaaS App (2026)
Multi-tenancy means one application serves multiple customers (tenants), each seeing only their own data. It's the foundation of B2B SaaS. Here's how to architect it properly in 2026.
Multi-Tenancy Models
1. Shared Database, Shared Schema (Most Common)
All tenants share one database. A tenant_id column on every table isolates data.
CREATE TABLE projects (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Every query includes tenant_id
SELECT * FROM projects WHERE tenant_id = 'abc-123';
Pros: Cheapest, simplest, easiest to maintain Cons: Data isolation relies on application logic. One bad query leaks data.
Best for: Most SaaS apps, especially early stage.
2. Shared Database, Separate Schemas
Each tenant gets their own database schema (Postgres schemas).
CREATE SCHEMA tenant_abc123;
CREATE TABLE tenant_abc123.projects (...);
SET search_path TO tenant_abc123;
SELECT * FROM projects; -- Only sees tenant's data
Pros: Stronger isolation. Easier per-tenant migrations. Cons: More complex. Schema management at scale is painful.
Best for: Regulated industries needing stronger isolation.
3. Separate Databases Per Tenant
Each tenant gets their own database instance.
Pros: Maximum isolation. Easy to comply with data residency requirements. Cons: Expensive. Complex to manage. Can't easily query across tenants.
Best for: Enterprise customers requiring dedicated infrastructure.
The Practical Architecture (2026)
For most SaaS apps, use shared database with tenant_id and add row-level security:
Stack
- Framework: Next.js or Hono
- Database: Neon or Supabase (Postgres)
- Auth: Clerk (built-in organization/tenant support)
- ORM: Drizzle
Step 1: Database Schema
-- Tenants (organizations)
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
plan TEXT DEFAULT 'free',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Users belong to tenants
CREATE TABLE tenant_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
user_id TEXT NOT NULL, -- From auth provider (Clerk user ID)
role TEXT DEFAULT 'member',
UNIQUE(tenant_id, user_id)
);
-- All data tables include tenant_id
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for fast tenant queries
CREATE INDEX idx_projects_tenant ON projects(tenant_id);
Step 2: Row-Level Security (Postgres)
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:
async function withTenant(tenantId: string, fn: () => Promise<any>) {
await db.execute(sql`SET LOCAL app.tenant_id = ${tenantId}`)
return fn()
}
This is your safety net. Even if application code forgets to filter by tenant_id, RLS prevents data leaks.
Step 3: Auth with Clerk Organizations
Clerk has built-in organization (tenant) support:
import { auth } from '@clerk/nextjs/server'
export async function GET() {
const { orgId } = auth()
if (!orgId) {
return Response.json({ error: 'No organization selected' }, { status: 403 })
}
// orgId is the tenant_id
const projects = await db.query.projects.findMany({
where: eq(projects.tenantId, orgId),
})
return Response.json(projects)
}
Step 4: Middleware for Tenant Context
// middleware.ts
import { clerkMiddleware } from '@clerk/nextjs/server'
export default clerkMiddleware((auth, req) => {
const { orgId } = auth()
// Ensure API routes always have a tenant context
if (req.nextUrl.pathname.startsWith('/api/') && !orgId) {
return Response.json({ error: 'Organization required' }, { status: 403 })
}
})
Step 5: Drizzle Schema with Tenant ID
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core'
export const tenants = pgTable('tenants', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
slug: text('slug').unique().notNull(),
plan: text('plan').default('free'),
})
export const projects = pgTable('projects', {
id: uuid('id').primaryKey().defaultRandom(),
tenantId: uuid('tenant_id').notNull().references(() => tenants.id),
name: text('name').notNull(),
createdAt: timestamp('created_at').defaultNow(),
})
Tenant-Aware Patterns
Data Access Layer
Never query without tenant context:
class TenantRepository {
constructor(private tenantId: string) {}
async getProjects() {
return db.query.projects.findMany({
where: eq(projects.tenantId, this.tenantId),
})
}
async createProject(name: string) {
return db.insert(projects).values({
tenantId: this.tenantId,
name,
})
}
}
Subdomain-Based Routing
acme.yourapp.com → tenant: acme
bigcorp.yourapp.com → tenant: bigcorp
function getTenantFromHost(host: string): string | null {
const subdomain = host.split('.')[0]
if (subdomain === 'www' || subdomain === 'app') return null
return subdomain
}
API Key Per Tenant
// API keys include tenant context
const apiKey = await db.query.apiKeys.findFirst({
where: eq(apiKeys.key, request.headers.get('x-api-key')),
})
const tenantId = apiKey.tenantId
Billing Per Tenant
Stripe Integration
Map Stripe customers to tenants:
// When tenant signs up for paid plan
const customer = await stripe.customers.create({
metadata: { tenantId: tenant.id },
})
const subscription = await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: 'price_pro_monthly' }],
})
await db.update(tenants)
.set({ stripeCustomerId: customer.id, plan: 'pro' })
.where(eq(tenants.id, tenant.id))
Usage-Based Limits
async function checkLimit(tenantId: string, feature: string) {
const tenant = await getTenant(tenantId)
const limits = PLAN_LIMITS[tenant.plan]
const usage = await getUsage(tenantId, feature)
if (usage >= limits[feature]) {
throw new Error(`${feature} limit reached. Upgrade to increase.`)
}
}
Common Mistakes
1. Forgetting tenant_id on a Query
One missing WHERE clause leaks all tenants' data. Mitigate with:
- Row-level security (Postgres RLS)
- ORM middleware that auto-adds tenant filter
- Code review checklist
2. No Index on tenant_id
Every table with tenant_id needs an index. Without it, queries scan all rows.
3. Global Uniqueness Constraints
Email uniqueness should be per-tenant, not global:
UNIQUE(tenant_id, email) -- ✅ Correct
UNIQUE(email) -- ❌ Wrong (blocks same email in different tenants)
4. Hardcoded Tenant in Background Jobs
Background jobs must include tenant context:
queue.add('sendReport', { tenantId: 'abc-123', reportId: '456' })
FAQ
Which multi-tenancy model should I use?
Shared database with tenant_id for 95% of SaaS apps. Separate databases only if enterprise customers contractually require it.
How do I handle data migrations?
With shared database, migrations apply to all tenants at once. With separate schemas/databases, you need a migration runner that iterates through tenants.
What about data residency (GDPR)?
For EU data residency, you can use database-level isolation for those tenants or route to an EU-region database. Neon and Supabase support multiple regions.
How many tenants can one database handle?
Postgres easily handles thousands of tenants in one database with proper indexing. Most SaaS apps never need to shard.
Bottom Line
Start with the simplest model: shared Postgres database with tenant_id columns, row-level security, and Clerk for auth. This handles everything from MVP to thousands of customers. Only add complexity (separate schemas, separate databases) when enterprise customers require it.