Multi-tenant SaaS Architecture with Next.js

13 min read2592 words

Two years ago, I built a multi-tenant SaaS platform that now serves 50,000+ users across 2,000+ organizations. The journey from single-tenant chaos to a properly architected multi-tenant system taught me the critical importance of getting tenant isolation right from day one.

Today, I'll share the complete architecture we use in production, including the Next.js middleware patterns, database design decisions, and security considerations that enable us to onboard new tenants in under 30 seconds while maintaining enterprise-grade isolation.

Multi-Tenancy Patterns: Choosing the Right Approach

The first critical decision is your tenancy model. After building and scaling multiple SaaS applications, here's what I've learned about each approach:

Pattern 1: Single Database, Shared Schema (Our Choice)

This is what we use for 90% of our tenants:

// lib/database/schema.ts
import { pgTable, text, uuid, timestamp, boolean } from 'drizzle-orm/pg-core';
 
// Every table includes tenant_id
export const organizations = pgTable('organizations', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  subdomain: text('subdomain').unique().notNull(),
  plan: text('plan').notNull().default('starter'),
  createdAt: timestamp('created_at').defaultNow(),
  isActive: boolean('is_active').default(true)
});
 
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').references(() => organizations.id).notNull(),
  email: text('email').notNull(),
  name: text('name').notNull(),
  role: text('role').notNull().default('user'),
  createdAt: timestamp('created_at').defaultNow()
});
 
export const projects = pgTable('projects', {
  id: uuid('id').primaryKey().defaultRandom(),
  tenantId: uuid('tenant_id').references(() => organizations.id).notNull(),
  name: text('name').notNull(),
  description: text('description'),
  createdBy: uuid('created_by').references(() => users.id).notNull(),
  createdAt: timestamp('created_at').defaultNow()
});
 
// Critical: Always create compound indexes with tenant_id first
export const usersByTenantIndex = 'idx_users_tenant_id';
export const projectsByTenantIndex = 'idx_projects_tenant_id_created_at';

Pattern 2: Database Per Tenant (Enterprise Customers)

For our largest customers who require strict compliance:

// lib/database/tenant-db-manager.ts
import { Pool } from 'pg';
 
export class TenantDatabaseManager {
  private static pools = new Map<string, Pool>();
  
  static async getTenantDatabase(tenantId: string): Promise<Pool> {
    if (this.pools.has(tenantId)) {
      return this.pools.get(tenantId)!;
    }
    
    const dbName = `tenant_${tenantId.replace('-', '_')}`;
    const pool = new Pool({
      host: process.env.DB_HOST,
      database: dbName,
      user: process.env.DB_USER,
      password: process.env.DB_PASSWORD,
      max: 10 // Smaller pool per tenant
    });
    
    this.pools.set(tenantId, pool);
    return pool;
  }
  
  static async createTenantDatabase(tenantId: string) {
    const adminPool = new Pool({
      host: process.env.DB_HOST,
      database: 'postgres',
      user: process.env.DB_ADMIN_USER,
      password: process.env.DB_ADMIN_PASSWORD,
    });
    
    const dbName = `tenant_${tenantId.replace('-', '_')}`;
    
    try {
      // Create database
      await adminPool.query(`CREATE DATABASE ${dbName}`);
      
      // Run migrations for new tenant
      const tenantPool = await this.getTenantDatabase(tenantId);
      await this.runMigrations(tenantPool);
      
      console.log(`Created database for tenant: ${tenantId}`);
    } catch (error) {
      console.error(`Failed to create tenant database: ${error}`);
      throw error;
    } finally {
      await adminPool.end();
    }
  }
  
  private static async runMigrations(pool: Pool) {
    // Run your schema migrations here
    const migrations = [
      'CREATE TABLE users (...)',
      'CREATE TABLE projects (...)',
      // ... all your table creation scripts
    ];
    
    for (const migration of migrations) {
      await pool.query(migration);
    }
  }
}

Next.js Middleware: The Heart of Multi-Tenancy

The middleware is where all tenant context is established. Here's our production implementation:

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyJWT } from './lib/auth/jwt';
import { getTenantConfig } from './lib/tenant/config';
 
export async function middleware(request: NextRequest) {
  const response = NextResponse.next();
  
  // 1. Extract tenant from subdomain or path
  const tenant = await extractTenant(request);
  
  if (!tenant) {
    return redirectToMainSite(request);
  }
  
  // 2. Validate tenant exists and is active
  const tenantConfig = await getTenantConfig(tenant);
  
  if (!tenantConfig || !tenantConfig.isActive) {
    return showTenantInactiveMessage();
  }
  
  // 3. Handle authentication
  const authResult = await handleAuthentication(request, tenant);
  
  if (authResult.shouldRedirect) {
    return authResult.response;
  }
  
  // 4. Set tenant context headers
  response.headers.set('x-tenant-id', tenantConfig.id);
  response.headers.set('x-tenant-subdomain', tenant);
  response.headers.set('x-tenant-plan', tenantConfig.plan);
  response.headers.set('x-user-id', authResult.userId || '');
  response.headers.set('x-user-role', authResult.userRole || '');
  
  // 5. Apply tenant-specific configurations
  return applyTenantMiddleware(request, response, tenantConfig);
}
 
async function extractTenant(request: NextRequest): Promise<string | null> {
  const { hostname, pathname } = request.nextUrl;
  
  // Handle subdomain routing (primary method)
  if (hostname.includes('.')) {
    const subdomain = hostname.split('.')[0];
    
    // Skip www and api subdomains
    if (['www', 'api', 'admin'].includes(subdomain)) {
      return null;
    }
    
    return subdomain;
  }
  
  // Fallback to path-based routing for development
  const pathMatch = pathname.match(/^\/tenant\/([^\/]+)/);
  return pathMatch ? pathMatch[1] : null;
}
 
async function handleAuthentication(
  request: NextRequest, 
  tenant: string
): Promise<{
  shouldRedirect: boolean;
  response?: NextResponse;
  userId?: string;
  userRole?: string;
}> {
  const token = request.cookies.get('auth_token')?.value;
  
  if (!token) {
    if (requiresAuth(request.nextUrl.pathname)) {
      return {
        shouldRedirect: true,
        response: NextResponse.redirect(
          new URL(`/login?redirect=${encodeURIComponent(request.nextUrl.pathname)}`, request.url)
        )
      };
    }
    return { shouldRedirect: false };
  }
  
  try {
    const payload = await verifyJWT(token);
    
    // Ensure user belongs to this tenant
    if (payload.tenantId !== tenant) {
      return {
        shouldRedirect: true,
        response: NextResponse.redirect(new URL('/unauthorized', request.url))
      };
    }
    
    return {
      shouldRedirect: false,
      userId: payload.userId,
      userRole: payload.role
    };
  } catch (error) {
    return {
      shouldRedirect: true,
      response: NextResponse.redirect(new URL('/login', request.url))
    };
  }
}
 
function requiresAuth(pathname: string): boolean {
  const publicPaths = ['/', '/login', '/signup', '/forgot-password', '/pricing'];
  const publicPatterns = ['/api/public', '/api/webhooks'];
  
  if (publicPaths.includes(pathname)) return false;
  
  return !publicPatterns.some(pattern => pathname.startsWith(pattern));
}
 
export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * - public assets
     */
    '/((?!_next/static|_next/image|favicon.ico|public/).*)',
  ],
};

Tenant Configuration Management

Each tenant needs custom configuration. Here's how we manage it:

// lib/tenant/config.ts
import { redis } from '../redis';
import { db } from '../database';
 
export interface TenantConfig {
  id: string;
  name: string;
  subdomain: string;
  plan: 'starter' | 'professional' | 'enterprise';
  isActive: boolean;
  features: {
    maxUsers: number;
    maxProjects: number;
    advancedAnalytics: boolean;
    customBranding: boolean;
    sso: boolean;
    apiAccess: boolean;
  };
  branding: {
    primaryColor: string;
    logoUrl?: string;
    customDomain?: string;
  };
  billing: {
    stripeCustomerId?: string;
    subscriptionId?: string;
    subscriptionStatus: string;
    currentPeriodEnd: Date;
  };
  createdAt: Date;
  updatedAt: Date;
}
 
export async function getTenantConfig(subdomain: string): Promise<TenantConfig | null> {
  // Try cache first
  const cacheKey = `tenant:${subdomain}`;
  const cached = await redis.get(cacheKey);
  
  if (cached) {
    return JSON.parse(cached);
  }
  
  // Fetch from database
  const tenant = await db
    .select()
    .from(organizations)
    .where(eq(organizations.subdomain, subdomain))
    .limit(1);
  
  if (!tenant.length) {
    return null;
  }
  
  const config: TenantConfig = {
    id: tenant[0].id,
    name: tenant[0].name,
    subdomain: tenant[0].subdomain,
    plan: tenant[0].plan,
    isActive: tenant[0].isActive,
    features: getPlanFeatures(tenant[0].plan),
    branding: tenant[0].branding || getDefaultBranding(),
    billing: tenant[0].billing || {},
    createdAt: tenant[0].createdAt,
    updatedAt: tenant[0].updatedAt
  };
  
  // Cache for 5 minutes
  await redis.setex(cacheKey, 300, JSON.stringify(config));
  
  return config;
}
 
function getPlanFeatures(plan: string) {
  const features = {
    starter: {
      maxUsers: 5,
      maxProjects: 10,
      advancedAnalytics: false,
      customBranding: false,
      sso: false,
      apiAccess: false
    },
    professional: {
      maxUsers: 50,
      maxProjects: 100,
      advancedAnalytics: true,
      customBranding: true,
      sso: false,
      apiAccess: true
    },
    enterprise: {
      maxUsers: -1, // Unlimited
      maxProjects: -1,
      advancedAnalytics: true,
      customBranding: true,
      sso: true,
      apiAccess: true
    }
  };
  
  return features[plan] || features.starter;
}
 
// Tenant-aware data access layer
export class TenantAwareDB {
  constructor(private tenantId: string) {}
  
  // All queries automatically scoped to tenant
  async getUsers() {
    return db
      .select()
      .from(users)
      .where(eq(users.tenantId, this.tenantId));
  }
  
  async createProject(data: Omit<typeof projects.$inferInsert, 'tenantId'>) {
    return db
      .insert(projects)
      .values({
        ...data,
        tenantId: this.tenantId
      })
      .returning();
  }
  
  async getProjects(userId?: string) {
    let query = db
      .select()
      .from(projects)
      .where(eq(projects.tenantId, this.tenantId));
    
    if (userId) {
      query = query.where(eq(projects.createdBy, userId));
    }
    
    return query;
  }
  
  // Generic tenant-scoped query method
  async scopedQuery<T>(
    table: any,
    additionalWhere?: any
  ) {
    let query = db
      .select()
      .from(table)
      .where(eq(table.tenantId, this.tenantId));
    
    if (additionalWhere) {
      query = query.where(additionalWhere);
    }
    
    return query;
  }
}

API Route Protection

Every API route needs tenant-aware access control:

// lib/middleware/api-auth.ts
import { NextRequest } from 'next/server';
import { TenantAwareDB } from '../tenant/config';
 
export interface AuthenticatedRequest extends NextRequest {
  tenant: {
    id: string;
    subdomain: string;
    plan: string;
    config: TenantConfig;
  };
  user: {
    id: string;
    email: string;
    role: string;
  };
  db: TenantAwareDB;
}
 
export function withAuth(handler: (req: AuthenticatedRequest) => Promise<Response>) {
  return async (request: NextRequest) => {
    // Extract tenant and user info from middleware headers
    const tenantId = request.headers.get('x-tenant-id');
    const userId = request.headers.get('x-user-id');
    const userRole = request.headers.get('x-user-role');
    
    if (!tenantId || !userId) {
      return new Response('Unauthorized', { status: 401 });
    }
    
    // Get full tenant config
    const tenantConfig = await getTenantConfig(
      request.headers.get('x-tenant-subdomain')!
    );
    
    if (!tenantConfig) {
      return new Response('Tenant not found', { status: 404 });
    }
    
    // Create tenant-aware database instance
    const tenantDb = new TenantAwareDB(tenantId);
    
    // Augment request with tenant context
    const authenticatedRequest = request as AuthenticatedRequest;
    authenticatedRequest.tenant = {
      id: tenantId,
      subdomain: tenantConfig.subdomain,
      plan: tenantConfig.plan,
      config: tenantConfig
    };
    authenticatedRequest.user = {
      id: userId,
      email: '', // Would fetch from user table if needed
      role: userRole || 'user'
    };
    authenticatedRequest.db = tenantDb;
    
    return handler(authenticatedRequest);
  };
}
 
// Usage in API routes
// app/api/projects/route.ts
import { withAuth } from '@/lib/middleware/api-auth';
 
export const GET = withAuth(async (req) => {
  const projects = await req.db.getProjects(req.user.id);
  
  return Response.json({ projects });
});
 
export const POST = withAuth(async (req) => {
  const body = await req.json();
  
  // Validate against tenant plan limits
  if (req.tenant.config.features.maxProjects !== -1) {
    const existingProjects = await req.db.getProjects();
    
    if (existingProjects.length >= req.tenant.config.features.maxProjects) {
      return Response.json(
        { error: 'Project limit reached for your plan' },
        { status: 403 }
      );
    }
  }
  
  const project = await req.db.createProject({
    name: body.name,
    description: body.description,
    createdBy: req.user.id
  });
  
  return Response.json({ project });
});

Tenant Onboarding Flow

Automated tenant provisioning is critical for scalable SaaS:

// lib/tenant/onboarding.ts
export interface OnboardingData {
  organizationName: string;
  subdomain: string;
  adminEmail: string;
  adminName: string;
  plan: 'starter' | 'professional' | 'enterprise';
  source?: string;
}
 
export class TenantOnboarding {
  static async createTenant(data: OnboardingData): Promise<{
    tenant: TenantConfig;
    adminUser: User;
    loginUrl: string;
  }> {
    const startTime = Date.now();
    console.log(`Starting tenant onboarding for ${data.subdomain}`);
    
    try {
      // 1. Validate subdomain availability
      await this.validateSubdomain(data.subdomain);
      
      // 2. Create tenant record
      const tenant = await this.createTenantRecord(data);
      
      // 3. Set up tenant database (if using database-per-tenant)
      if (tenant.plan === 'enterprise') {
        await TenantDatabaseManager.createTenantDatabase(tenant.id);
      }
      
      // 4. Create admin user
      const adminUser = await this.createAdminUser(tenant.id, {
        email: data.adminEmail,
        name: data.adminName
      });
      
      // 5. Set up billing if paid plan
      if (data.plan !== 'starter') {
        await this.setupBilling(tenant.id, data);
      }
      
      // 6. Send welcome emails
      await this.sendWelcomeEmails(tenant, adminUser);
      
      // 7. Create default data
      await this.createDefaultData(tenant.id, adminUser.id);
      
      const duration = Date.now() - startTime;
      console.log(`Tenant onboarding completed in ${duration}ms`);
      
      return {
        tenant,
        adminUser,
        loginUrl: `https://${data.subdomain}.${process.env.DOMAIN}/login`
      };
    } catch (error) {
      console.error('Tenant onboarding failed:', error);
      // Clean up any partially created resources
      await this.cleanupFailedOnboarding(data.subdomain);
      throw error;
    }
  }
  
  private static async validateSubdomain(subdomain: string) {
    // Check format
    if (!/^[a-z0-9-]+$/.test(subdomain)) {
      throw new Error('Invalid subdomain format');
    }
    
    // Check reserved words
    const reserved = ['www', 'api', 'admin', 'app', 'mail', 'ftp'];
    if (reserved.includes(subdomain)) {
      throw new Error('Subdomain is reserved');
    }
    
    // Check availability
    const existing = await db
      .select()
      .from(organizations)
      .where(eq(organizations.subdomain, subdomain))
      .limit(1);
    
    if (existing.length > 0) {
      throw new Error('Subdomain already taken');
    }
  }
  
  private static async createTenantRecord(data: OnboardingData) {
    const [tenant] = await db
      .insert(organizations)
      .values({
        name: data.organizationName,
        subdomain: data.subdomain,
        plan: data.plan,
        isActive: true,
        branding: this.getDefaultBranding(),
        billing: {},
        metadata: {
          source: data.source || 'direct',
          onboardedAt: new Date()
        }
      })
      .returning();
    
    // Warm the cache
    const config = {
      id: tenant.id,
      name: tenant.name,
      subdomain: tenant.subdomain,
      plan: tenant.plan,
      isActive: tenant.isActive,
      features: getPlanFeatures(tenant.plan),
      branding: tenant.branding,
      billing: tenant.billing,
      createdAt: tenant.createdAt,
      updatedAt: tenant.updatedAt
    };
    
    const cacheKey = `tenant:${data.subdomain}`;
    await redis.setex(cacheKey, 300, JSON.stringify(config));
    
    return config;
  }
  
  private static async createDefaultData(tenantId: string, adminUserId: string) {
    const tenantDb = new TenantAwareDB(tenantId);
    
    // Create welcome project
    await tenantDb.createProject({
      name: 'Welcome Project',
      description: 'Your first project to get started',
      createdBy: adminUserId
    });
    
    // Create sample data based on plan
    // This would include default templates, integrations, etc.
  }
  
  private static async setupBilling(tenantId: string, data: OnboardingData) {
    // Integrate with Stripe or your billing provider
    const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
    
    const customer = await stripe.customers.create({
      email: data.adminEmail,
      name: data.organizationName,
      metadata: {
        tenantId,
        plan: data.plan
      }
    });
    
    // Update tenant with Stripe customer ID
    await db
      .update(organizations)
      .set({
        billing: {
          stripeCustomerId: customer.id,
          subscriptionStatus: 'trialing'
        }
      })
      .where(eq(organizations.id, tenantId));
  }
}
 
// API endpoint for onboarding
// app/api/onboard/route.ts
export async function POST(request: NextRequest) {
  const data = await request.json();
  
  try {
    const result = await TenantOnboarding.createTenant(data);
    
    return Response.json({
      success: true,
      tenant: result.tenant,
      loginUrl: result.loginUrl
    });
  } catch (error) {
    return Response.json(
      { error: error.message },
      { status: 400 }
    );
  }
}

Security and Data Isolation

Security is paramount in multi-tenant systems:

// lib/security/tenant-isolation.ts
export class TenantSecurityGuard {
  // Validate all database queries include tenant scope
  static validateQuery(query: string, tenantId: string): boolean {
    // This would be more sophisticated in production
    // Using a SQL parser to ensure tenant_id is in WHERE clauses
    return query.includes(`tenant_id = '${tenantId}'`) ||
           query.includes(`"tenantId" = '${tenantId}'`);
  }
  
  // Row-level security for PostgreSQL
  static async enableRowLevelSecurity(tenantId: string) {
    await db.execute(sql`
      CREATE POLICY tenant_isolation_policy ON users
      FOR ALL TO app_user
      USING (tenant_id = current_setting('app.current_tenant_id'));
      
      CREATE POLICY tenant_isolation_policy ON projects  
      FOR ALL TO app_user
      USING (tenant_id = current_setting('app.current_tenant_id'));
      
      ALTER TABLE users ENABLE ROW LEVEL SECURITY;
      ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
      
      -- Set tenant context for this session
      SELECT set_config('app.current_tenant_id', '${tenantId}', true);
    `);
  }
  
  // Audit logging for security events
  static async logSecurityEvent(
    tenantId: string,
    userId: string,
    action: string,
    resource: string,
    metadata?: any
  ) {
    await db.insert(securityAuditLog).values({
      tenantId,
      userId,
      action,
      resource,
      metadata,
      timestamp: new Date(),
      ipAddress: metadata?.ipAddress,
      userAgent: metadata?.userAgent
    });
  }
}
 
// Middleware to set database session context
export async function setTenantContext(tenantId: string) {
  await db.execute(sql`
    SELECT set_config('app.current_tenant_id', '${tenantId}', true)
  `);
}

Performance Optimization

Multi-tenant systems have unique performance challenges:

// lib/performance/tenant-caching.ts
export class TenantCacheManager {
  private static readonly CACHE_TTL = {
    tenant_config: 300,    // 5 minutes
    user_permissions: 600, // 10 minutes
    feature_flags: 1800   // 30 minutes
  };
  
  static async getTenantData<T>(
    cacheKey: string,
    tenantId: string,
    fetcher: () => Promise<T>,
    ttl?: number
  ): Promise<T> {
    const key = `${tenantId}:${cacheKey}`;
    
    try {
      const cached = await redis.get(key);
      if (cached) {
        return JSON.parse(cached);
      }
    } catch (error) {
      console.warn('Cache read failed:', error);
    }
    
    const data = await fetcher();
    
    try {
      await redis.setex(
        key, 
        ttl || this.CACHE_TTL.tenant_config,
        JSON.stringify(data)
      );
    } catch (error) {
      console.warn('Cache write failed:', error);
    }
    
    return data;
  }
  
  static async invalidateTenantCache(tenantId: string, pattern?: string) {
    const searchPattern = pattern 
      ? `${tenantId}:${pattern}*`
      : `${tenantId}:*`;
    
    const keys = await redis.keys(searchPattern);
    
    if (keys.length > 0) {
      await redis.del(...keys);
    }
  }
  
  // Warm cache for new tenants
  static async warmTenantCache(tenantId: string) {
    const tenant = await getTenantConfig(tenantId);
    if (!tenant) return;
    
    // Preload common data
    await Promise.all([
      this.getTenantData(
        'users',
        tenantId,
        () => new TenantAwareDB(tenantId).getUsers()
      ),
      this.getTenantData(
        'projects',
        tenantId,
        () => new TenantAwareDB(tenantId).getProjects()
      )
    ]);
  }
}

Monitoring and Observability

Track tenant-specific metrics for better insights:

// lib/monitoring/tenant-metrics.ts
export class TenantMetrics {
  static async trackEvent(
    tenantId: string,
    event: string,
    properties?: Record<string, any>
  ) {
    const metric = {
      tenantId,
      event,
      properties: properties || {},
      timestamp: new Date(),
      environment: process.env.NODE_ENV
    };
    
    // Send to your analytics service
    await Promise.all([
      this.sendToAnalytics(metric),
      this.updateRealTimeMetrics(tenantId, event),
      this.checkAlerts(tenantId, event, properties)
    ]);
  }
  
  static async getTenantMetrics(tenantId: string, timeframe: string = '24h') {
    return {
      activeUsers: await this.getActiveUsers(tenantId, timeframe),
      apiCalls: await this.getApiCalls(tenantId, timeframe),
      storageUsage: await this.getStorageUsage(tenantId),
      featureUsage: await this.getFeatureUsage(tenantId, timeframe),
      performance: await this.getPerformanceMetrics(tenantId, timeframe)
    };
  }
  
  private static async checkAlerts(
    tenantId: string,
    event: string,
    properties?: Record<string, any>
  ) {
    // Check for unusual usage patterns
    if (event === 'api_call') {
      const recentCalls = await this.getRecentApiCalls(tenantId, '1m');
      if (recentCalls > 1000) { // Rate limiting threshold
        await this.sendAlert(tenantId, 'high_api_usage', {
          calls: recentCalls,
          timeframe: '1m'
        });
      }
    }
  }
  
  static async generateTenantReport(tenantId: string) {
    const tenant = await getTenantConfig(tenantId);
    if (!tenant) throw new Error('Tenant not found');
    
    const metrics = await this.getTenantMetrics(tenantId, '30d');
    
    return {
      tenant: {
        name: tenant.name,
        plan: tenant.plan,
        createdAt: tenant.createdAt
      },
      usage: metrics,
      billing: await this.getBillingMetrics(tenantId),
      health: await this.getHealthScore(tenantId),
      recommendations: await this.getUsageRecommendations(tenantId, metrics)
    };
  }
}

Key Takeaways

Building a multi-tenant SaaS with Next.js requires careful planning:

  1. Choose the right tenancy model - Single DB works for most use cases, dedicated databases for enterprise
  2. Middleware is critical - All tenant context flows through your middleware
  3. Security by default - Always scope queries to tenant ID, implement row-level security
  4. Cache strategically - Tenant configurations and permissions should be cached with appropriate TTLs
  5. Monitor per-tenant - Track usage, performance, and costs at the tenant level
  6. Automate onboarding - Fast, reliable tenant provisioning is essential for growth

The architecture I've shared scales from 10 to 10,000 tenants. Start simple with the shared database model, add complexity as you grow, and always prioritize security and tenant isolation above all else.