Multi-Tenant Architecture in Next.js: A Complete Blueprint
February 24, 20263 min read71 views
Multi-Tenant Architecture in Next.js: A Complete Blueprint
A production-grade guide to building multi-tenant SaaS applications with Next.js 14+, covering tenant resolution, data isolation, feature flags, and operational considerations.
Table of Contents
- Architectural Decisions: The Foundation
- Tenant Resolution Strategies
- Middleware-Driven Tenant Resolution
- Database Architecture
- Tenant Isolation Patterns
- Per-Tenant Feature Flags
- Caching Strategies
- Security Considerations
- Operational Concerns
Architectural Decisions: The Foundation
Multi-Tenancy Models Compared
┌─────────────────────────────────────────────────────────────────────────────┐
│ MULTI-TENANCY SPECTRUM │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ SHARED EVERYTHING HYBRID ISOLATED EVERYTHING │
│ ◄────────────────────────────┼────────────────────────────────────────────►│
│ │ │
│ ┌─────────────┐ ┌──────┴──────┐ ┌─────────────┐ │
│ │ Shared DB │ │Schema/Tenant│ │ DB/Tenant │ │
│ │ Shared │ │ Shared │ │ Dedicated │ │
│ │ Schema │ │ Infra │ │ Infra │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │
│ Cost: $ Cost: $$ Cost: $$$ │
│ Isolation: Low Isolation: Medium Isolation: High │
│ Complexity: Low Complexity: Medium Complexity: High │
│ Scale: High Scale: Medium Scale: Per-tenant │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Decision Framework
| Factor | Shared Schema | Schema-per-Tenant | Database-per-Tenant |
|---|---|---|---|
| Data isolation requirements | Regulatory allows shared | Moderate compliance | PCI-DSS, HIPAA, SOC2 Type II |
| Tenant size variance | Homogeneous | 10x variance | 100x+ variance |
| Custom schema needs | None | Minor | Extensive |
| Noisy neighbor tolerance | High | Medium | None |
| Operational overhead | Low | Medium | High |
| Data residency requirements | Single region | Per-schema possible | Full control |
| Backup/restore granularity | All-or-nothing | Per-schema | Per-tenant |
Tenant Resolution Strategies
Strategy Comparison
┌──────────────────────────────────────────────────────────────────────────┐
│ TENANT RESOLUTION STRATEGIES │
├──────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. SUBDOMAIN-BASED │
│ tenant1.app.com ──► Extract "tenant1" from Host header │
│ ├── Pros: Clean URLs, browser-isolated cookies, intuitive │
│ ├── Cons: SSL wildcard certs, DNS complexity, local dev pain │
│ └── Best for: B2B SaaS, white-label platforms │
│ │
│ 2. PATH-BASED │
│ app.com/tenant1/dashboard ──► Extract from URL path │
│ ├── Pros: Simple SSL, easy local dev, single domain │
│ ├── Cons: URL pollution, routing complexity, cookie scope issues │
│ └── Best for: Internal tools, admin panels, prototypes │
│ │
│ 3. HEADER-BASED │
│ X-Tenant-ID: tenant1 ──► Extract from custom header │
│ ├── Pros: Clean URLs, flexible, API-friendly │
│ ├── Cons: Can't bookmark, requires client config, not browser-native │
│ └── Best for: API-first platforms, mobile backends, microservices │
│ │
│ 4. HYBRID (Recommended for production) │
│ Subdomain → Path fallback → Header fallback → JWT claim │
│ ├── Pros: Maximum flexibility, graceful degradation │
│ └── Cons: Complexity, multiple code paths to test │
│ │
└──────────────────────────────────────────────────────────────────────────┘
Subdomain Extraction: Edge Cases
// src/lib/tenant/extract-subdomain.ts
interface SubdomainResult {
tenant: string | null;
isCustomDomain: boolean;
originalHost: string;
}
const PLATFORM_DOMAINS = new Set([
'myapp.com',
'myapp.io',
'staging.myapp.com',
'localhost',
]);
const RESERVED_SUBDOMAINS = new Set([
'www',
'api',
'app',
'admin',
'dashboard',
'mail',
'ftp',
'staging',
'dev',
'test',
'status',
'docs',
'help',
'support',
'blog',
]);
export function extractTenantFromHost(host: string): SubdomainResult {
// Strip port for local development
const hostWithoutPort = host.split(':')[0];
const parts = hostWithoutPort.split('.');
// Handle localhost specially
if (hostWithoutPort === 'localhost' || hostWithoutPort.endsWith('.localhost')) {
const localParts = hostWithoutPort.split('.');
if (localParts.length > 1 && localParts[0] !== 'localhost') {
return {
tenant: localParts[0],
isCustomDomain: false,
originalHost: host,
};
}
return { tenant: null, isCustomDomain: false, originalHost: host };
}
// Check if this is a custom domain (not our platform domain)
const baseDomain = parts.slice(-2).join('.');
const isCustomDomain = !PLATFORM_DOMAINS.has(baseDomain) &&
!PLATFORM_DOMAINS.has(hostWithoutPort);
if (isCustomDomain) {
// Custom domain - will need database lookup
return {
tenant: null, // Resolved via DB lookup
isCustomDomain: true,
originalHost: host,
};
}
// Platform subdomain extraction
// Handle: tenant.myapp.com, tenant.staging.myapp.com
if (parts.length >= 3) {
const potentialTenant = parts[0];
if (RESERVED_SUBDOMAINS.has(potentialTenant.toLowerCase())) {
return { tenant: null, isCustomDomain: false, originalHost: host };
}
// Validate tenant slug format
if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(potentialTenant)) {
return { tenant: null, isCustomDomain: false, originalHost: host };
}
return {
tenant: potentialTenant.toLowerCase(),
isCustomDomain: false,
originalHost: host,
};
}
return { tenant: null, isCustomDomain: false, originalHost: host };
}
Middleware-Driven Tenant Resolution
The Complete Middleware Implementation
// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { extractTenantFromHost } from '@/lib/tenant/extract-subdomain';
// Tenant context passed via headers (Edge Runtime compatible)
const TENANT_HEADER = 'x-tenant-id';
const TENANT_SLUG_HEADER = 'x-tenant-slug';
const TENANT_PLAN_HEADER = 'x-tenant-plan';
const TENANT_FEATURES_HEADER = 'x-tenant-features';
// Cache tenant lookups at the edge (Vercel Edge Config, or in-memory for self-hosted)
const tenantCache = new Map<string, { data: TenantConfig; expires: number }>();
const CACHE_TTL_MS = 60_000; // 1 minute - balance between freshness and performance
interface TenantConfig {
id: string;
slug: string;
name: string;
plan: 'free' | 'pro' | 'enterprise';
features: string[];
customDomain?: string;
databaseUrl?: string; // For database-per-tenant
schemaName?: string; // For schema-per-tenant
status: 'active' | 'suspended' | 'deleted';
settings: {
maxUsers: number;
maxStorage: number;
allowedOrigins: string[];
};
}
async function resolveTenant(
identifier: string,
isCustomDomain: boolean
): Promise<TenantConfig | null> {
const cacheKey = `${isCustomDomain ? 'domain' : 'slug'}:${identifier}`;
const cached = tenantCache.get(cacheKey);
if (cached && cached.expires > Date.now()) {
return cached.data;
}
try {
// In production, this hits your tenant service or Edge Config
// Using Edge Config (Vercel) for ultra-low latency:
// const tenant = await get(`tenants/${cacheKey}`);
// Or hit your API (ensure it's fast - consider edge deployment)
const response = await fetch(
`${process.env.TENANT_SERVICE_URL}/api/tenants/resolve`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}`,
},
body: JSON.stringify({ identifier, isCustomDomain }),
// Critical: don't let slow tenant service block all requests
signal: AbortSignal.timeout(2000),
}
);
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Tenant service returned ${response.status}`);
}
const tenant: TenantConfig = await response.json();
// Cache successful lookups
tenantCache.set(cacheKey, {
data: tenant,
expires: Date.now() + CACHE_TTL_MS,
});
return tenant;
} catch (error) {
console.error('Tenant resolution failed:', { identifier, isCustomDomain, error });
// Return stale cache on error (stale-while-error pattern)
if (cached) {
console.warn('Returning stale tenant cache due to resolution failure');
return cached.data;
}
return null;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Skip tenant resolution for static assets and health checks
if (
pathname.startsWith('/_next') ||
pathname.startsWith('/api/health') ||
pathname.startsWith('/favicon') ||
pathname.match(/\.(ico|png|jpg|jpeg|svg|css|js|woff|woff2)$/)
) {
return NextResponse.next();
}
const host = request.headers.get('host') || '';
const { tenant: tenantSlug, isCustomDomain, originalHost } = extractTenantFromHost(host);
// No tenant context needed for marketing site
if (!tenantSlug && !isCustomDomain) {
// This is the root domain - serve marketing site
if (pathname.startsWith('/app') || pathname.startsWith('/api')) {
// Trying to access app without tenant context
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// Resolve tenant from slug or custom domain
const identifier = isCustomDomain ? originalHost : tenantSlug;
const tenant = await resolveTenant(identifier!, isCustomDomain);
if (!tenant) {
// Tenant not found - show 404 or redirect
return NextResponse.rewrite(new URL('/tenant-not-found', request.url));
}
if (tenant.status === 'suspended') {
return NextResponse.rewrite(new URL('/tenant-suspended', request.url));
}
if (tenant.status === 'deleted') {
return NextResponse.rewrite(new URL('/tenant-not-found', request.url));
}
// Inject tenant context into request headers
const requestHeaders = new Headers(request.headers);
requestHeaders.set(TENANT_HEADER, tenant.id);
requestHeaders.set(TENANT_SLUG_HEADER, tenant.slug);
requestHeaders.set(TENANT_PLAN_HEADER, tenant.plan);
requestHeaders.set(TENANT_FEATURES_HEADER, JSON.stringify(tenant.features));
// For database-per-tenant: pass connection info
if (tenant.databaseUrl) {
requestHeaders.set('x-tenant-db-url', tenant.databaseUrl);
}
if (tenant.schemaName) {
requestHeaders.set('x-tenant-schema', tenant.schemaName);
}
const response = NextResponse.next({
request: { headers: requestHeaders },
});
// Set CORS headers for custom domains
if (isCustomDomain && tenant.settings.allowedOrigins.length > 0) {
const origin = request.headers.get('origin');
if (origin && tenant.settings.allowedOrigins.includes(origin)) {
response.headers.set('Access-Control-Allow-Origin', origin);
}
}
return response;
}
export const config = {
matcher: [
/*
* Match all request paths except:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - public folder
*/
'/((?!_next/static|_next/image|favicon.ico|public/).*)',
],
};
Server-Side Tenant Context Access
// src/lib/tenant/context.ts
import { headers } from 'next/headers';
import { cache } from 'react';
interface TenantContext {
id: string;
slug: string;
plan: 'free' | 'pro' | 'enterprise';
features: string[];
dbUrl?: string;
schema?: string;
}
// React cache ensures single resolution per request
export const getTenantContext = cache(async (): Promise<TenantContext> => {
const headersList = await headers();
const id = headersList.get('x-tenant-id');
const slug = headersList.get('x-tenant-slug');
const plan = headersList.get('x-tenant-plan') as TenantContext['plan'];
const featuresJson = headersList.get('x-tenant-features');
if (!id || !slug || !plan) {
throw new TenantContextError('Tenant context not available');
}
return {
id,
slug,
plan,
features: featuresJson ? JSON.parse(featuresJson) : [],
dbUrl: headersList.get('x-tenant-db-url') || undefined,
schema: headersList.get('x-tenant-schema') || undefined,
};
});
export class TenantContextError extends Error {
constructor(message: string) {
super(message);
this.name = 'TenantContextError';
}
}
// Usage in Server Components
// const tenant = await getTenantContext();
Database Architecture
Strategy 1: Shared Schema with Tenant ID
┌────────────────────────────────────────────────────────────────────────┐
│ SHARED SCHEMA ARCHITECTURE │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL │ │
│ │ ┌─────────────────────────────────────────────────────────────┐│ │
│ │ │ public schema ││ │
│ │ │ ││ │
│ │ │ users projects ││ │
│ │ │ ┌──────────────────┐ ┌──────────────────┐ ││ │
│ │ │ │ id │ │ id │ ││ │
│ │ │ │ tenant_id (FK)───┼────│ tenant_id (FK) │ ││ │
│ │ │ │ email │ │ name │ ││ │
│ │ │ │ ... │ │ ... │ ││ │
│ │ │ └──────────────────┘ └──────────────────┘ ││ │
│ │ │ ││ │
│ │ │ tenants ││ │
│ │ │ ┌──────────────────┐ ││ │
│ │ │ │ id (PK) │ ││ │
│ │ │ │ slug (UNIQUE) │ ││ │
│ │ │ │ plan │ ││ │
│ │ │ │ ... │ ││ │
│ │ │ └──────────────────┘ ││ │
│ │ └─────────────────────────────────────────────────────────────┘│ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ CRITICAL: Every query MUST include tenant_id in WHERE clause │
│ │
└────────────────────────────────────────────────────────────────────────┘
Prisma Schema for Shared Tenancy
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
previewFeatures = ["multiSchema"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model Tenant {
id String @id @default(cuid())
slug String @unique
name String
plan Plan @default(FREE)
status TenantStatus @default(ACTIVE)
// Limits based on plan
maxUsers Int @default(5)
maxProjects Int @default(3)
maxStorageMb Int @default(100)
// Feature flags (JSON for flexibility)
features Json @default("{}")
// Custom domain support
customDomain String? @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
users User[]
projects Project[]
@@index([slug])
@@index([customDomain])
}
model User {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
email String
name String?
role UserRole @default(MEMBER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Unique email per tenant, not globally
@@unique([tenantId, email])
@@index([tenantId])
}
model Project {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
name String
description String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
@@index([tenantId, createdAt])
}
enum Plan {
FREE
PRO
ENTERPRISE
}
enum TenantStatus {
ACTIVE
SUSPENDED
DELETED
}
enum UserRole {
OWNER
ADMIN
MEMBER
VIEWER
}
Row-Level Security (Critical for Shared Schema)
-- migrations/20240115_enable_rls.sql
-- Enable RLS on all tenant-scoped tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create a function to get current tenant from session
CREATE OR REPLACE FUNCTION current_tenant_id()
RETURNS TEXT AS $$
SELECT current_setting('app.current_tenant_id', TRUE);
$$ LANGUAGE SQL STABLE;
-- Policy for users table
CREATE POLICY tenant_isolation_users ON users
FOR ALL
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
-- Policy for projects table
CREATE POLICY tenant_isolation_projects ON projects
FOR ALL
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
-- Bypass policy for service role (migrations, admin operations)
CREATE POLICY service_bypass_users ON users
FOR ALL
TO service_role
USING (TRUE)
WITH CHECK (TRUE);
CREATE POLICY service_bypass_projects ON projects
FOR ALL
TO service_role
USING (TRUE)
WITH CHECK (TRUE);
Prisma Client with Tenant Context
// src/lib/db/client.ts
import { PrismaClient } from '@prisma/client';
import { getTenantContext } from '@/lib/tenant/context';
// Base client - use for cross-tenant operations only
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prismaBase = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'error', 'warn']
: ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prismaBase;
}
// Tenant-scoped client using Prisma's extension API
export async function getTenantPrisma() {
const tenant = await getTenantContext();
return prismaBase.$extends({
query: {
$allOperations({ model, operation, args, query }) {
// Models that require tenant scoping
const tenantScopedModels = ['User', 'Project', 'Task', 'Document'];
if (!tenantScopedModels.includes(model ?? '')) {
return query(args);
}
// Inject tenant_id into all reads
if (['findMany', 'findFirst', 'findUnique', 'count', 'aggregate'].includes(operation)) {
args.where = { ...args.where, tenantId: tenant.id };
}
// Inject tenant_id into all writes
if (['create', 'createMany'].includes(operation)) {
if (Array.isArray(args.data)) {
args.data = args.data.map((d: any) => ({ ...d, tenantId: tenant.id }));
} else {
args.data = { ...args.data, tenantId: tenant.id };
}
}
// Scope updates and deletes
if (['update', 'updateMany', 'delete', 'deleteMany'].includes(operation)) {
args.where = { ...args.where, tenantId: tenant.id };
}
return query(args);
},
},
});
}
// Alternative: Using Prisma's $executeRaw with RLS
export async function withTenantRLS<T>(
tenantId: string,
callback: (prisma: PrismaClient) => Promise<T>
): Promise<T> {
// Set tenant context for RLS
await prismaBase.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, TRUE)`;
try {
return await callback(prismaBase);
} finally {
// Clear tenant context
await prismaBase.$executeRaw`SELECT set_config('app.current_tenant_id', '', TRUE)`;
}
}
Strategy 2: Schema-per-Tenant
┌────────────────────────────────────────────────────────────────────────┐
│ SCHEMA-PER-TENANT ARCHITECTURE │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ PostgreSQL │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ public │ │ tenant_abc │ │ tenant_xyz │ │ │
│ │ │ (shared) │ │ │ │ │ │ │
│ │ │ │ │ users │ │ users │ │ │
│ │ │ tenants │ │ projects │ │ projects │ │ │
│ │ │ migrations │ │ tasks │ │ tasks │ │ │
│ │ │ │ │ │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ │ │ │
│ │ search_path = 'tenant_abc,public' │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ PROS: Strong isolation, per-tenant backup/restore, custom indexes │
│ CONS: Migration complexity, connection overhead, schema sprawl │
│ │
└────────────────────────────────────────────────────────────────────────┘
// src/lib/db/schema-per-tenant.ts
import { Pool, PoolClient } from 'pg';
import { getTenantContext } from '@/lib/tenant/context';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
export async function withTenantSchema<T>(
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const tenant = await getTenantContext();
const schemaName = `tenant_${tenant.slug}`;
// Validate schema name to prevent SQL injection
if (!/^tenant_[a-z0-9_]+$/.test(schemaName)) {
throw new Error('Invalid tenant schema name');
}
const client = await pool.connect();
try {
// Set search path to tenant schema
await client.query(`SET search_path TO ${schemaName}, public`);
return await callback(client);
} finally {
// Reset search path before returning to pool
await client.query('SET search_path TO public');
client.release();
}
}
// Schema creation for new tenants
export async function createTenantSchema(tenantSlug: string): Promise<void> {
const schemaName = `tenant_${tenantSlug}`;
if (!/^tenant_[a-z0-9_]+$/.test(schemaName)) {
throw new Error('Invalid tenant slug');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
// Create schema
await client.query(`CREATE SCHEMA IF NOT EXISTS ${schemaName}`);
// Copy table structure from template schema
await client.query(`
CREATE TABLE ${schemaName}.users (LIKE template.users INCLUDING ALL);
CREATE TABLE ${schemaName}.projects (LIKE template.projects INCLUDING ALL);
CREATE TABLE ${schemaName}.tasks (LIKE template.tasks INCLUDING ALL);
`);
// Grant permissions
await client.query(`
GRANT USAGE ON SCHEMA ${schemaName} TO app_user;
GRANT ALL ON ALL TABLES IN SCHEMA ${schemaName} TO app_user;
GRANT ALL ON ALL SEQUENCES IN SCHEMA ${schemaName} TO app_user;
`);
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
Strategy 3: Database-per-Tenant
// src/lib/db/database-per-tenant.ts
import { PrismaClient } from '@prisma/client';
import { getTenantContext } from '@/lib/tenant/context';
import { LRUCache } from 'lru-cache';
// Connection pool cache - prevents creating new clients per request
const clientCache = new LRUCache<string, PrismaClient>({
max: 100, // Maximum tenants with active connections
ttl: 1000 * 60 * 5, // 5 minutes
dispose: async (client, key) => {
console.log(`Disposing Prisma client for tenant: ${key}`);
await client.$disconnect();
},
});
export async function getTenantDatabase(): Promise<PrismaClient> {
const tenant = await getTenantContext();
if (!tenant.dbUrl) {
throw new Error('Database URL not configured for tenant');
}
const cachedClient = clientCache.get(tenant.id);
if (cachedClient) {
return cachedClient;
}
// Create new client for this tenant
const client = new PrismaClient({
datasources: {
db: { url: tenant.dbUrl },
},
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
// Verify connection before caching
await client.$connect();
clientCache.set(tenant.id, client);
return client;
}
// Cleanup function for graceful shutdown
export async function disconnectAllTenants(): Promise<void> {
const promises: Promise<void>[] = [];
clientCache.forEach((client) => {
promises.push(client.$disconnect());
});
await Promise.all(promises);
clientCache.clear();
}
// Register shutdown hooks
process.on('SIGTERM', disconnectAllTenants);
process.on('SIGINT', disconnectAllTenants);
Tenant Isolation Patterns
Request Context Isolation
// src/lib/tenant/isolation.ts
import { AsyncLocalStorage } from 'async_hooks';
interface TenantRequestContext {
tenantId: string;
tenantSlug: string;
userId?: string;
requestId: string;
startTime: number;
}
export const tenantStorage = new AsyncLocalStorage<TenantRequestContext>();
export function runWithTenantContext<T>(
context: TenantRequestContext,
fn: () => T
): T {
return tenantStorage.run(context, fn);
}
export function getCurrentTenantContext(): TenantRequestContext {
const context = tenantStorage.getStore();
if (!context) {
throw new Error('No tenant context available - ensure middleware is configured');
}
return context;
}
// Wrapper for API routes
export function withTenantIsolation<T>(
handler: (context: TenantRequestContext) => Promise<T>
): () => Promise<T> {
return async () => {
const context = getCurrentTenantContext();
// Log for audit trail
console.log({
event: 'tenant_request',
tenantId: context.tenantId,
requestId: context.requestId,
timestamp: new Date().toISOString(),
});
return handler(context);
};
}
Cross-Tenant Operation Guard
// src/lib/tenant/guards.ts
import { getCurrentTenantContext } from './isolation';
export class CrossTenantAccessError extends Error {
constructor(
public readonly requestedTenantId: string,
public readonly currentTenantId: string
) {
super(`Cross-tenant access denied: requested ${requestedTenantId}, current ${currentTenantId}`);
this.name = 'CrossTenantAccessError';
}
}
export function assertSameTenant(resourceTenantId: string): void {
const { tenantId } = getCurrentTenantContext();
if (resourceTenantId !== tenantId) {
throw new CrossTenantAccessError(resourceTenantId, tenantId);
}
}
// Decorator for service methods
export function TenantScoped() {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
// Ensure tenant context exists before executing
getCurrentTenantContext();
return originalMethod.apply(this, args);
};
return descriptor;
};
}
Per-Tenant Feature Flags
Feature Flag Architecture
┌────────────────────────────────────────────────────────────────────────┐
│ FEATURE FLAG RESOLUTION FLOW │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Request ──► Middleware ──► Feature Flag Service ──► Response │
│ │ │ │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ Priority │ │
│ │ │ Order: │ │
│ │ │ │ │
│ │ │ 1. User │ (A/B tests, beta users) │
│ │ │ 2. Tenant │ (enterprise features) │
│ │ │ 3. Plan │ (tier-based features) │
│ │ │ 4. Global │ (kill switches) │
│ │ │ 5. Default │ (code defaults) │
│ │ │ │ │
│ │ └──────────────┘ │
│ │ │ │
│ ▼ ▼ │
│ x-tenant-features Cached in Edge │
│ │
└────────────────────────────────────────────────────────────────────────┘
Feature Flag Implementation
// src/lib/features/flags.ts
import { getTenantContext } from '@/lib/tenant/context';
import { cache } from 'react';
// Feature definitions with metadata
const FEATURE_DEFINITIONS = {
// Core features by plan
'projects.unlimited': { plans: ['pro', 'enterprise'], default: false },
'analytics.advanced': { plans: ['pro', 'enterprise'], default: false },
'api.rate_limit_increased': { plans: ['enterprise'], default: false },
'sso.enabled': { plans: ['enterprise'], default: false },
'audit.logs': { plans: ['enterprise'], default: false },
// Beta features (explicit opt-in)
'beta.new_editor': { plans: [], default: false, beta: true },
'beta.ai_assistant': { plans: [], default: false, beta: true },
// Gradual rollout features
'rollout.new_dashboard': { plans: [], default: false, rollout: true },
// Operational flags (kill switches)
'ops.maintenance_mode': { plans: [], default: false, ops: true },
'ops.read_only': { plans: [], default: false, ops: true },
} as const;
type FeatureKey = keyof typeof FEATURE_DEFINITIONS;
interface FeatureFlagContext {
tenantId: string;
tenantPlan: 'free' | 'pro' | 'enterprise';
tenantFeatures: string[]; // Explicit overrides
userId?: string;
userFlags?: string[]; // User-specific overrides (A/B tests)
}
class FeatureFlagService {
private globalOverrides: Map<string, boolean> = new Map();
private rolloutPercentages: Map<string, number> = new Map();
async isEnabled(
feature: FeatureKey,
context: FeatureFlagContext
): Promise<boolean> {
const definition = FEATURE_DEFINITIONS[feature];
// 1. Check global kill switch / ops flags
if (this.globalOverrides.has(feature)) {
return this.globalOverrides.get(feature)!;
}
// 2. Check explicit tenant override
if (context.tenantFeatures.includes(feature)) {
return true;
}
if (context.tenantFeatures.includes(`!${feature}`)) {
return false; // Explicit disable
}
// 3. Check user-level flags (A/B tests, beta users)
if (context.userFlags?.includes(feature)) {
return true;
}
// 4. Check plan-based access
if (definition.plans.includes(context.tenantPlan)) {
return true;
}
// 5. Check gradual rollout (hash-based for consistency)
if ('rollout' in definition && definition.rollout) {
const percentage = this.rolloutPercentages.get(feature) ?? 0;
if (percentage > 0) {
const hash = this.hashTenant(context.tenantId, feature);
return hash < percentage;
}
}
// 6. Return default
return definition.default;
}
private hashTenant(tenantId: string, feature: string): number {
// Simple hash for deterministic rollout
const str = `${tenantId}:${feature}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return Math.abs(hash) % 100;
}
setGlobalOverride(feature: FeatureKey, enabled: boolean): void {
this.globalOverrides.set(feature, enabled);
}
setRolloutPercentage(feature: FeatureKey, percentage: number): void {
this.rolloutPercentages.set(feature, Math.max(0, Math.min(100, percentage)));
}
}
const featureFlagService = new FeatureFlagService();
// React Server Component compatible hook
export const useFeatureFlag = cache(async (feature: FeatureKey): Promise<boolean> => {
const tenant = await getTenantContext();
return featureFlagService.isEnabled(feature, {
tenantId: tenant.id,
tenantPlan: tenant.plan,
tenantFeatures: tenant.features,
});
});
// Batch check for multiple features
export const useFeatureFlags = cache(async <T extends FeatureKey[]>(
features: T
): Promise<Record<T[number], boolean>> => {
const tenant = await getTenantContext();
const results = await Promise.all(
features.map(f => featureFlagService.isEnabled(f, {
tenantId: tenant.id,
tenantPlan: tenant.plan,
tenantFeatures: tenant.features,
}))
);
return Object.fromEntries(
features.map((f, i) => [f, results[i]])
) as Record<T[number], boolean>;
});
Feature-Gated Component Pattern
// src/components/features/FeatureGate.tsx
import { ReactNode } from 'react';
import { useFeatureFlag } from '@/lib/features/flags';
interface FeatureGateProps {
feature: Parameters<typeof useFeatureFlag>[0];
children: ReactNode;
fallback?: ReactNode;
}
export async function FeatureGate({
feature,
children,
fallback = null
}: FeatureGateProps) {
const isEnabled = await useFeatureFlag(feature);
return isEnabled ? <>{children}</> : <>{fallback}</>;
}
// Usage in Server Components:
// <FeatureGate feature="beta.new_editor">
// <NewEditor />
// </FeatureGate>
Caching Strategies
Multi-Layer Cache Architecture
┌────────────────────────────────────────────────────────────────────────┐
│ TENANT-AWARE CACHING LAYERS │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Request ──► Edge Cache ──► App Memory ──► Redis ──► Database │
│ │
│ ┌────────────────┐ │
│ │ Edge Cache │ CDN/Vercel Edge │
│ │ │ - Static assets (no tenant key) │
│ │ │ - Tenant config (tenant:slug key) │
│ │ TTL: seconds │ - Public pages (cache-control headers) │
│ └────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ App Memory │ LRU Cache / React Cache │
│ │ │ - Request deduplication │
│ │ │ - Tenant context (per-request) │
│ │ TTL: request │ - Feature flags │
│ └────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Redis │ Distributed cache │
│ │ │ - Session data (tenant:user:session) │
│ │ │ - Query results (tenant:query:hash) │
│ │ TTL: minutes │ - Rate limiting (tenant:rate:endpoint) │
│ └────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Database │ PostgreSQL with query cache │
│ │ │ - Source of truth │
│ │ TTL: N/A │ - Use pg_stat_statements for monitoring │
│ └────────────────┘ │
│ │
│ CRITICAL: All cache keys MUST include tenant identifier │
│ │
└────────────────────────────────────────────────────────────────────────┘
Tenant-Aware Redis Cache
// src/lib/cache/redis.ts
import { Redis } from 'ioredis';
import { getTenantContext } from '@/lib/tenant/context';
const redis = new Redis(process.env.REDIS_URL!, {
maxRetriesPerRequest: 3,
retryDelayOnFailover: 100,
});
interface CacheOptions {
ttl?: number; // seconds
tags?: string[]; // for cache invalidation
}
export class TenantCache {
private prefix = 'tc'; // tenant cache
private async buildKey(key: string): Promise<string> {
const tenant = await getTenantContext();
return `${this.prefix}:${tenant.id}:${key}`;
}
async get<T>(key: string): Promise<T | null> {
const fullKey = await this.buildKey(key);
const data = await redis.get(fullKey);
if (!data) return null;
try {
return JSON.parse(data) as T;
} catch {
return null;
}
}
async set<T>(key: string, value: T, options: CacheOptions = {}): Promise<void> {
const tenant = await getTenantContext();
const fullKey = await this.buildKey(key);
const serialized = JSON.stringify(value);
const pipeline = redis.pipeline();
if (options.ttl) {
pipeline.setex(fullKey, options.ttl, serialized);
} else {
pipeline.set(fullKey, serialized);
}
// Track tags for invalidation
if (options.tags?.length) {
for (const tag of options.tags) {
pipeline.sadd(`${this.prefix}:${tenant.id}:tag:${tag}`, fullKey);
}
}
await pipeline.exec();
}
async delete(key: string): Promise<void> {
const fullKey = await this.buildKey(key);
await redis.del(fullKey);
}
async invalidateByTag(tag: string): Promise<void> {
const tenant = await getTenantContext();
const tagKey = `${this.prefix}:${tenant.id}:tag:${tag}`;
const keys = await redis.smembers(tagKey);
if (keys.length > 0) {
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(tagKey);
await pipeline.exec();
}
}
// Invalidate all cache for a tenant (use carefully)
async invalidateAll(): Promise<void> {
const tenant = await getTenantContext();
const pattern = `${this.prefix}:${tenant.id}:*`;
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}
}
export const tenantCache = new TenantCache();
// Usage:
// await tenantCache.set('user:profile', userData, { ttl: 300, tags: ['users'] });
// const profile = await tenantCache.get<UserProfile>('user:profile');
// await tenantCache.invalidateByTag('users');
Security Considerations
Security Checklist
┌────────────────────────────────────────────────────────────────────────┐
│ MULTI-TENANT SECURITY MATRIX │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ DATA ISOLATION │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ ☐ Row-Level Security (RLS) enabled on all tenant tables │ │
│ │ ☐ Application-level tenant filtering on every query │ │
│ │ ☐ No tenant ID in URL paths (leak via referrer headers) │ │
│ │ ☐ Tenant ID validated, not trusted from client │ │
│ │ ☐ Cross-tenant joins prevented at ORM/query level │ │
│ │ ☐ Bulk operations scoped to single tenant │ │
│ │ ☐ Export/backup operations tenant-isolated │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ AUTHENTICATION & SESSION │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ ☐ Session tokens bound to tenant ID │ │
│ │ ☐ JWT claims include tenant context │ │
│ │ ☐ Cookie domain scoped to tenant subdomain │ │
│ │ ☐ CSRF tokens tenant-specific │ │
│ │ ☐ Password reset tokens scoped to tenant │ │
│ │ ☐ MFA enrollment per-tenant (enterprise) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ API SECURITY │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ ☐ API keys scoped to tenant │ │
│ │ ☐ Rate limiting per-tenant (not just per-IP) │ │
│ │ ☐ Webhook signatures include tenant context │ │
│ │ ☐ GraphQL/API queries can't traverse tenant boundaries │ │
│ │ ☐ File uploads in tenant-specific paths/buckets │ │
│ │ ☐ Signed URLs expire and include tenant validation │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ INFRASTRUCTURE │ │
│ ├─────────────────────────────────────────────────────────────────┤ │
│ │ ☐ Logs include tenant ID for forensics │ │
│ │ ☐ Metrics tagged with tenant for anomaly detection │ │
│ │ ☐ Encryption keys rotated per-tenant (enterprise) │ │
│ │ ☐ Network isolation for database-per-tenant │ │
│ │ ☐ Tenant suspension immediately revokes access │ │
│ │ ☐ Data deletion compliant with tenant off-boarding │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
└────────────────────────────────────────────────────────────────────────┘
Secure Tenant Context Validation
// src/lib/auth/validate-tenant-access.ts
import { getServerSession } from 'next-auth';
import { getTenantContext } from '@/lib/tenant/context';
import { prismaBase } from '@/lib/db/client';
export class TenantAccessError extends Error {
constructor(
message: string,
public readonly code: 'NO_SESSION' | 'NO_TENANT' | 'NOT_MEMBER' | 'SUSPENDED'
) {
super(message);
this.name = 'TenantAccessError';
}
}
interface ValidatedContext {
userId: string;
tenantId: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
permissions: string[];
}
export async function validateTenantAccess(): Promise<ValidatedContext> {
const session = await getServerSession();
if (!session?.user?.id) {
throw new TenantAccessError('Authentication required', 'NO_SESSION');
}
const tenant = await getTenantContext();
// Verify user is actually a member of this tenant
// Never trust middleware headers alone for authorization
const membership = await prismaBase.membership.findUnique({
where: {
userId_tenantId: {
userId: session.user.id,
tenantId: tenant.id,
},
},
include: {
tenant: { select: { status: true } },
},
});
if (!membership) {
throw new TenantAccessError(
`User ${session.user.id} is not a member of tenant ${tenant.id}`,
'NOT_MEMBER'
);
}
if (membership.tenant.status === 'suspended') {
throw new TenantAccessError('Tenant is suspended', 'SUSPENDED');
}
return {
userId: session.user.id,
tenantId: tenant.id,
role: membership.role,
permissions: computePermissions(membership.role),
};
}
function computePermissions(role: string): string[] {
const rolePermissions: Record<string, string[]> = {
owner: ['*'],
admin: ['read', 'write', 'delete', 'invite', 'settings'],
member: ['read', 'write'],
viewer: ['read'],
};
return rolePermissions[role] ?? [];
}
// Decorator for API routes
export function requireTenantAccess(requiredPermission?: string) {
return async function <T>(
handler: (context: ValidatedContext) => Promise<T>
): Promise<T> {
const context = await validateTenantAccess();
if (requiredPermission &&
!context.permissions.includes('*') &&
!context.permissions.includes(requiredPermission)) {
throw new TenantAccessError(
`Missing permission: ${requiredPermission}`,
'NOT_MEMBER'
);
}
return handler(context);
};
}
Operational Concerns
Tenant Lifecycle Management
// src/services/tenant/lifecycle.ts
import { prismaBase } from '@/lib/db/client';
import { tenantCache } from '@/lib/cache/redis';
import { createTenantSchema } from '@/lib/db/schema-per-tenant';
interface CreateTenantInput {
slug: string;
name: string;
ownerEmail: string;
plan?: 'free' | 'pro' | 'enterprise';
}
export async function provisionTenant(input: CreateTenantInput): Promise<string> {
const { slug, name, ownerEmail, plan = 'free' } = input;
// Validate slug availability
const existing = await prismaBase.tenant.findUnique({
where: { slug },
});
if (existing) {
throw new Error(`Tenant slug '${slug}' is already taken`);
}
// Transaction: create tenant + owner membership
const tenant = await prismaBase.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
slug,
name,
plan,
status: 'active',
features: getDefaultFeatures(plan),
},
});
// Find or create owner user
let owner = await tx.user.findUnique({
where: { email: ownerEmail },
});
if (!owner) {
owner = await tx.user.create({
data: { email: ownerEmail },
});
}
// Create membership
await tx.membership.create({
data: {
userId: owner.id,
tenantId: tenant.id,
role: 'owner',
},
});
return tenant;
});
// Post-transaction: schema creation (if schema-per-tenant)
if (process.env.TENANT_ISOLATION === 'schema') {
await createTenantSchema(slug);
}
// Warm cache
await tenantCache.set(`tenant:${slug}`, tenant, { ttl: 3600 });
// Send welcome email (async)
await queueEmail('tenant-welcome', { tenantId: tenant.id, email: ownerEmail });
return tenant.id;
}
export async function suspendTenant(tenantId: string, reason: string): Promise<void> {
await prismaBase.tenant.update({
where: { id: tenantId },
data: {
status: 'suspended',
suspendedAt: new Date(),
suspendReason: reason,
},
});
// Invalidate all cached data
const tenant = await prismaBase.tenant.findUnique({
where: { id: tenantId },
select: { slug: true },
});
if (tenant) {
await invalidateTenantFromEdge(tenant.slug);
}
// Revoke all active sessions
await prismaBase.session.deleteMany({
where: {
membership: { tenantId },
},
});
}
export async function deleteTenant(tenantId: string): Promise<void> {
const tenant = await prismaBase.tenant.findUnique({
where: { id: tenantId },
select: { slug: true, status: true },
});
if (!tenant) {
throw new Error('Tenant not found');
}
if (tenant.status !== 'suspended') {
throw new Error('Tenant must be suspended before deletion');
}
// Schedule data deletion (comply with data retention policies)
await prismaBase.tenant.update({
where: { id: tenantId },
data: {
status: 'deleted',
deletedAt: new Date(),
dataRetentionUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
},
});
// Queue background job for actual data purge
await queueJob('purge-tenant-data', {
tenantId,
executeAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
});
}
function getDefaultFeatures(plan: string): Record<string, boolean> {
const planFeatures: Record<string, Record<string, boolean>> = {
free: {},
pro: { 'projects.unlimited': true, 'analytics.advanced': true },
enterprise: {
'projects.unlimited': true,
'analytics.advanced': true,
'sso.enabled': true,
'audit.logs': true,
},
};
return planFeatures[plan] ?? {};
}
Monitoring & Observability
// src/lib/observability/tenant-metrics.ts
import { metrics } from '@opentelemetry/api';
const meter = metrics.getMeter('multi-tenant-app');
// Counters
const requestCounter = meter.createCounter('tenant_requests_total', {
description: 'Total requests per tenant',
});
const errorCounter = meter.createCounter('tenant_errors_total', {
description: 'Total errors per tenant',
});
// Histograms
const requestDuration = meter.createHistogram('tenant_request_duration_ms', {
description: 'Request duration in milliseconds per tenant',
});
const dbQueryDuration = meter.createHistogram('tenant_db_query_duration_ms', {
description: 'Database query duration per tenant',
});
// Gauges
const activeUsersGauge = meter.createObservableGauge('tenant_active_users', {
description: 'Number of active users per tenant',
});
export function recordRequest(tenantId: string, endpoint: string, durationMs: number) {
const attributes = { tenant_id: tenantId, endpoint };
requestCounter.add(1, attributes);
requestDuration.record(durationMs, attributes);
}
export function recordError(tenantId: string, errorType: string, endpoint: string) {
errorCounter.add(1, { tenant_id: tenantId, error_type: errorType, endpoint });
}
export function recordDbQuery(tenantId: string, operation: string, durationMs: number) {
dbQueryDuration.record(durationMs, { tenant_id: tenantId, operation });
}
// Structured logging with tenant context
export function tenantLog(
level: 'info' | 'warn' | 'error',
message: string,
tenantId: string,
meta?: Record<string, unknown>
) {
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
tenant_id: tenantId,
...meta,
};
// Output JSON for log aggregation (Datadog, CloudWatch, etc.)
console[level](JSON.stringify(logEntry));
}
Quick Reference: Decision Matrix
| Scenario | Recommended Architecture |
|---|---|
| SaaS startup, <100 tenants | Shared schema + RLS |
| Enterprise B2B, compliance required | Schema-per-tenant |
| Regulated industries (healthcare, finance) | Database-per-tenant |
| High tenant size variance (1 user to 10K) | Hybrid: small tenants shared, large isolated |
| White-label reseller platform | Database-per-tenant + custom domains |
| Internal multi-team tool | Shared schema (simpler ops) |
Summary
Multi-tenant architecture is not a binary choice but a spectrum. The key decisions:
- Start simple (shared schema) unless compliance mandates isolation
- Design for migration - abstract tenant resolution so you can change strategies
- Never trust client-provided tenant context - always validate server-side
- Cache keys must include tenant ID - this is non-negotiable
- RLS is your safety net - even with application-level filtering
- Monitor per-tenant - you can't optimize what you can't measure
The middleware pattern in Next.js 14+ provides an elegant foundation for tenant resolution, but the real complexity lies in database architecture, caching strategy, and operational procedures for tenant lifecycle management.
What did you think?