Multi-tenant SaaS Architecture with Next.js
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:
- Choose the right tenancy model - Single DB works for most use cases, dedicated databases for enterprise
- Middleware is critical - All tenant context flows through your middleware
- Security by default - Always scope queries to tenant ID, implement row-level security
- Cache strategically - Tenant configurations and permissions should be cached with appropriate TTLs
- Monitor per-tenant - Track usage, performance, and costs at the tenant level
- 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.