Table of Contents
Building a multi-tenant SaaS application requires careful architectural decisions. The wrong choice early on can lead to scaling nightmares, security vulnerabilities, and high operational costs. This guide covers everything you need to design a robust multi-tenant system.
Multi-Tenancy Models
There are three primary approaches to multi-tenancy:
1. Shared Database, Shared Schema
All tenants share the same tables with a tenant_id column.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL REFERENCES tenants(id),
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
Pros:
- Simple to implement
- Easy to maintain
- Cost-effective
Cons:
- Risk of data leakage
- Noisy neighbor problems
- Complex queries
2. Shared Database, Separate Schemas
Each tenant gets their own PostgreSQL schema.
-- Create schema per tenant
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
-- Tables within each schema
CREATE TABLE tenant_acme.users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255)
);
Pros:
- Better isolation
- Easier data export
- Schema-level permissions
Cons:
- More complex migrations
- Connection pool challenges
- Moderate maintenance overhead
3. Separate Databases
Each tenant gets their own database.
Pros:
- Complete isolation
- Independent scaling
- Easy compliance
Cons:
- High operational cost
- Complex connection management
- Difficult cross-tenant operations
Recommended: Hybrid Approach
For most SaaS applications, I recommend a hybrid approach:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Application Layer β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Tenant Middleware β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Small Tenants (Shared) β Large Tenants (Isolated) β
β βββββββββββββββββββββββ β βββββββββββββββββββββββ β
β β Shared DB + Schema β β β Dedicated Database β β
β β tenant_id column β β β or Separate Schema β β
β βββββββββββββββββββββββ β βββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Implementing Tenant Context
Node.js with AsyncLocalStorage
// lib/tenant-context.ts
import { AsyncLocalStorage } from 'async_hooks';
interface TenantContext {
tenantId: string;
tenantSlug: string;
plan: 'free' | 'pro' | 'enterprise';
}
export const tenantStorage = new AsyncLocalStorage<TenantContext>();
export function getTenant(): TenantContext {
const tenant = tenantStorage.getStore();
if (!tenant) {
throw new Error('No tenant context found');
}
return tenant;
}
export function runWithTenant<T>(tenant: TenantContext, fn: () => T): T {
return tenantStorage.run(tenant, fn);
}
Express Middleware
// middleware/tenant.ts
import { Request, Response, NextFunction } from 'express';
import { runWithTenant } from '../lib/tenant-context';
import { db } from '../lib/database';
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// Extract tenant from subdomain or header
const tenantSlug = extractTenantSlug(req);
if (!tenantSlug) {
return res.status(400).json({ error: 'Tenant not specified' });
}
// Load tenant from database
const tenant = await db.tenants.findBySlug(tenantSlug);
if (!tenant) {
return res.status(404).json({ error: 'Tenant not found' });
}
// Run the rest of the request with tenant context
runWithTenant(
{
tenantId: tenant.id,
tenantSlug: tenant.slug,
plan: tenant.plan,
},
() => next()
);
}
function extractTenantSlug(req: Request): string | null {
// From subdomain: acme.yourapp.com
const host = req.hostname;
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
return subdomain;
}
// From header: X-Tenant-ID
const headerTenant = req.headers['x-tenant-id'];
if (typeof headerTenant === 'string') {
return headerTenant;
}
return null;
}
Row-Level Security with PostgreSQL
PostgreSQLβs Row-Level Security (RLS) provides database-enforced tenant isolation:
-- Enable RLS on tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Create policy for tenant isolation
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant_id')::INTEGER);
-- Force RLS for all users (including table owner)
ALTER TABLE users FORCE ROW LEVEL SECURITY;
Setting Tenant Context in Queries
// lib/database.ts
import { Pool, PoolClient } from 'pg';
import { getTenant } from './tenant-context';
const pool = new Pool();
export async function withTenantContext<T>(
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const tenant = getTenant();
const client = await pool.connect();
try {
// Set tenant context for RLS
await client.query(
'SET app.current_tenant_id = $1',
[tenant.tenantId]
);
return await fn(client);
} finally {
// Reset and release
await client.query('RESET app.current_tenant_id');
client.release();
}
}
// Usage
const users = await withTenantContext(async (client) => {
const result = await client.query('SELECT * FROM users');
return result.rows; // Automatically filtered by tenant!
});
Feature Flags per Tenant
// lib/features.ts
interface FeatureFlags {
advancedAnalytics: boolean;
apiAccess: boolean;
customBranding: boolean;
ssoEnabled: boolean;
maxUsers: number;
maxStorage: number; // in GB
}
const planFeatures: Record<string, FeatureFlags> = {
free: {
advancedAnalytics: false,
apiAccess: false,
customBranding: false,
ssoEnabled: false,
maxUsers: 5,
maxStorage: 1,
},
pro: {
advancedAnalytics: true,
apiAccess: true,
customBranding: false,
ssoEnabled: false,
maxUsers: 50,
maxStorage: 50,
},
enterprise: {
advancedAnalytics: true,
apiAccess: true,
customBranding: true,
ssoEnabled: true,
maxUsers: Infinity,
maxStorage: 1000,
},
};
export function getFeatures(): FeatureFlags {
const tenant = getTenant();
return planFeatures[tenant.plan];
}
export function hasFeature(feature: keyof FeatureFlags): boolean {
const features = getFeatures();
const value = features[feature];
return typeof value === 'boolean' ? value : value > 0;
}
Billing and Usage Tracking
// lib/usage.ts
interface UsageMetric {
tenantId: string;
metric: string;
value: number;
timestamp: Date;
}
export class UsageTracker {
private buffer: UsageMetric[] = [];
private flushInterval: NodeJS.Timer;
constructor() {
// Flush every 30 seconds
this.flushInterval = setInterval(() => this.flush(), 30000);
}
track(metric: string, value: number = 1) {
const tenant = getTenant();
this.buffer.push({
tenantId: tenant.tenantId,
metric,
value,
timestamp: new Date(),
});
}
private async flush() {
if (this.buffer.length === 0) return;
const metrics = [...this.buffer];
this.buffer = [];
await db.usageMetrics.insertMany(metrics);
}
}
// Usage
const usage = new UsageTracker();
// Track API calls
app.use((req, res, next) => {
usage.track('api_calls');
next();
});
// Track storage
async function uploadFile(file: Buffer) {
usage.track('storage_bytes', file.length);
// ...
}
Database Migrations Strategy
// migrations/runner.ts
import { getTenants, getTenantConnection } from '../lib/database';
async function runMigrations() {
const tenants = await getTenants();
for (const tenant of tenants) {
console.log(`Migrating tenant: ${tenant.slug}`);
const connection = await getTenantConnection(tenant);
try {
await connection.runPendingMigrations();
console.log(`β Tenant ${tenant.slug} migrated`);
} catch (error) {
console.error(`β Tenant ${tenant.slug} failed:`, error);
// Continue with other tenants
} finally {
await connection.close();
}
}
}
// For schema-per-tenant approach
async function runSchemaAwareMigration(migrationFn: Function) {
const schemas = await db.query(`
SELECT schema_name FROM information_schema.schemata
WHERE schema_name LIKE 'tenant_%'
`);
for (const { schema_name } of schemas.rows) {
await db.query(`SET search_path TO ${schema_name}`);
await migrationFn();
}
await db.query('SET search_path TO public');
}
Performance Optimization
Connection Pooling per Tenant
// lib/connection-pool.ts
import { Pool } from 'pg';
const tenantPools = new Map<string, Pool>();
export function getTenantPool(tenantId: string): Pool {
let pool = tenantPools.get(tenantId);
if (!pool) {
pool = new Pool({
connectionString: getTenantConnectionString(tenantId),
max: 10, // Per-tenant connection limit
idleTimeoutMillis: 30000,
});
tenantPools.set(tenantId, pool);
}
return pool;
}
// Cleanup idle pools periodically
setInterval(() => {
for (const [tenantId, pool] of tenantPools) {
if (pool.idleCount === pool.totalCount) {
pool.end();
tenantPools.delete(tenantId);
}
}
}, 60000);
Caching with Tenant Isolation
// lib/cache.ts
import Redis from 'ioredis';
import { getTenant } from './tenant-context';
const redis = new Redis();
export const cache = {
async get<T>(key: string): Promise<T | null> {
const tenant = getTenant();
const fullKey = `tenant:${tenant.tenantId}:${key}`;
const value = await redis.get(fullKey);
return value ? JSON.parse(value) : null;
},
async set(key: string, value: unknown, ttlSeconds = 3600): Promise<void> {
const tenant = getTenant();
const fullKey = `tenant:${tenant.tenantId}:${key}`;
await redis.setex(fullKey, ttlSeconds, JSON.stringify(value));
},
async invalidate(pattern: string): Promise<void> {
const tenant = getTenant();
const keys = await redis.keys(`tenant:${tenant.tenantId}:${pattern}`);
if (keys.length > 0) {
await redis.del(...keys);
}
},
};
Security Checklist
- Row-Level Security enabled on all tenant tables
- Tenant ID validated on every request
- Cross-tenant queries explicitly prevented
- API keys scoped to tenant
- Audit logging includes tenant context
- Data encryption at rest per tenant
- Separate encryption keys per tenant (enterprise)
- Regular security audits
Conclusion
Building a multi-tenant SaaS requires balancing:
- Isolation: Security and compliance needs
- Efficiency: Resource utilization and costs
- Complexity: Development and operational overhead
Start with the simplest approach (shared schema) and evolve as needed. Use PostgreSQL RLS for database-level isolation, and implement proper tenant context throughout your application.
Building a SaaS? Share your multi-tenancy challenges in the comments!
In-Article Ad
Dev Mode
Alex Chen
Senior Full-Stack Developer
I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.
Enjoyed this article?
Subscribe to get more tech content delivered to your inbox.
Related Articles
Building a Production-Ready REST API with Node.js and TypeScript
Learn to build scalable REST APIs with Node.js, Express, TypeScript, and PostgreSQL. Includes authentication, validation, and error handling.
PostgreSQL JSONB: Store and Query JSON Like a Pro
Master PostgreSQL JSONB for flexible data storage. Learn indexing, querying, and performance optimization for JSON data.
PostgreSQL Indexing Strategies: A Complete Performance Guide
Learn how to optimize PostgreSQL performance with the right indexing strategies. Covers B-tree, GIN, GiST indexes and when to use each.