Back to Blog

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

  1. Architectural Decisions: The Foundation
  2. Tenant Resolution Strategies
  3. Middleware-Driven Tenant Resolution
  4. Database Architecture
  5. Tenant Isolation Patterns
  6. Per-Tenant Feature Flags
  7. Caching Strategies
  8. Security Considerations
  9. 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

FactorShared SchemaSchema-per-TenantDatabase-per-Tenant
Data isolation requirementsRegulatory allows sharedModerate compliancePCI-DSS, HIPAA, SOC2 Type II
Tenant size varianceHomogeneous10x variance100x+ variance
Custom schema needsNoneMinorExtensive
Noisy neighbor toleranceHighMediumNone
Operational overheadLowMediumHigh
Data residency requirementsSingle regionPer-schema possibleFull control
Backup/restore granularityAll-or-nothingPer-schemaPer-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

ScenarioRecommended Architecture
SaaS startup, <100 tenantsShared schema + RLS
Enterprise B2B, compliance requiredSchema-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 platformDatabase-per-tenant + custom domains
Internal multi-team toolShared schema (simpler ops)

Summary

Multi-tenant architecture is not a binary choice but a spectrum. The key decisions:

  1. Start simple (shared schema) unless compliance mandates isolation
  2. Design for migration - abstract tenant resolution so you can change strategies
  3. Never trust client-provided tenant context - always validate server-side
  4. Cache keys must include tenant ID - this is non-negotiable
  5. RLS is your safety net - even with application-level filtering
  6. 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?

© 2026 Vidhya Sagar Thakur. All rights reserved.