The Architecture of a Scalable Multitenant SaaS App Using Next.js, Prisma & Kubernetes
Design patterns for building enterprise-grade multitenant SaaS applications. Covers tenant isolation, data partitioning, authentication, and Kubernetes deployment strategies.

The Multitenant Challenge
Building a SaaS application is hard. Building one that serves 1,000+ organizations with isolated data, custom configurations, and independent scaling is exponentially harder. Get the architecture wrong early, and you'll pay the migration cost forever.
This guide distills lessons from architecting three multitenant SaaS platforms—one serving 50K organizations, processing 2M requests/day, with 99.9% uptime SLAs.
Tenant Isolation Strategies: The Foundation
Your first architectural decision shapes everything else: how do you isolate tenant data?
Strategy 1: Shared Database + Row-Level Security (RLS)
Best for: 10-100 tenants, cost-sensitive startups, rapid iteration
-- PostgreSQL RLS policy
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::uuid);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;// Prisma middleware to set tenant context
prisma.$use(async (params, next) => {
if (params.model) {
await prisma.$executeRaw`SET LOCAL app.tenant_id = ${tenantId}`
}
return next(params)
})
Pros: Simple, cost-effective, easy backup/restore
Cons: Risk of tenant data leaks if RLS policy fails, harder to scale individual tenants
Strategy 2: Database Per Tenant
Best for: Enterprise customers, regulatory compliance (HIPAA, SOC 2), custom SLAs
// Dynamic Prisma client per tenant
import { PrismaClient } from '@prisma/client'
const tenantClients = new Map<string, PrismaClient>()
export function getTenantPrisma(tenantId: string) {
if (!tenantClients.has(tenantId)) {
const client = new PrismaClient({
datasources: {
db: {
url: `postgresql://user:pass@db.example.com/tenant_${tenantId}`
}
}
})
tenantClients.set(tenantId, client)
}
return tenantClients.get(tenantId)!
}
Pros: Perfect data isolation, independent scaling, easier compliance audits
Cons: Higher infrastructure costs, complex migrations, connection pool management
Strategy 3: Schema Per Tenant (Hybrid)
Best for: 100-1,000 tenants, PostgreSQL shops, balance of cost and isolation
-- Each tenant gets a schema in shared database
CREATE SCHEMA tenant_acme;
CREATE TABLE tenant_acme.users (...);
-- Set search_path per request
SET search_path TO tenant_acme, public;
Pros: Better isolation than RLS, cheaper than separate databases, shared connection pool
Cons: PostgreSQL-specific, migration complexity for 1000+ schemas
Tenant Context Middleware
Every request must identify the tenant. Common patterns:
- Subdomain:
acme.yoursaas.com→ tenant_slug = "acme" - Custom domain:
app.acmecorp.com→ lookup tenant by CNAME - Header:
X-Tenant-ID: acme(for APIs)
// Next.js middleware
import { NextRequest, NextResponse } from 'next/server'
export async function middleware(request: NextRequest) {
const host = request.headers.get('host') || ''
const subdomain = host.split('.')[0]
// Extract tenant from subdomain
if (subdomain && subdomain !== 'www') {
const tenant = await getTenantBySlug(subdomain)
if (!tenant) {
return NextResponse.redirect(new URL('/tenant-not-found', request.url))
}
// Inject tenant into request headers for downstream use
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-tenant-id', tenant.id)
requestHeaders.set('x-tenant-slug', tenant.slug)
return NextResponse.next({
request: {
headers: requestHeaders,
},
})
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}Tenant-Aware Authentication
JWTs must include tenant context to prevent cross-tenant access:
// JWT payload structure
interface TenantJWT {
sub: string // user_id
email: string
tenant_id: string // CRITICAL: tenant isolation
tenant_role: 'admin' | 'member' | 'viewer'
iat: number
exp: number
}
// Verification logic
export async function verifyTenantAccess(token: string, requiredTenantId: string) {
const decoded = jwt.verify(token, SECRET) as TenantJWT
if (decoded.tenant_id !== requiredTenantId) {
throw new Error('Cross-tenant access denied')
}
return decoded
}Database Schema Design
Core tables for multitenant architecture:
-- Tenants table (shared, never partitioned)
CREATE TABLE tenants (
id UUID PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
plan TEXT NOT NULL, -- 'free', 'pro', 'enterprise'
status TEXT NOT NULL, -- 'active', 'suspended', 'deleted'
created_at TIMESTAMPTZ DEFAULT NOW(),
settings JSONB DEFAULT '{}'::jsonb
);
-- Users table (tenant-partitioned)
CREATE TABLE users (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
role TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.tenant_id')::uuid);Kubernetes Deployment Architecture
Deploy Next.js app with autoscaling and tenant-aware routing:
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: saas-app
spec:
replicas: 3
selector:
matchLabels:
app: saas-app
template:
metadata:
labels:
app: saas-app
spec:
containers:
- name: nextjs
image: your-registry/saas-app:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secrets
key: connection-string
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: saas-app-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: saas-app
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70Rate Limiting Per Tenant
Prevent one tenant from overwhelming your infrastructure:
import { RateLimiterRedis } from 'rate-limiter-flexible'
import Redis from 'ioredis'
const redis = new Redis(process.env.REDIS_URL)
const rateLimiters = {
free: new RateLimiterRedis({
storeClient: redis,
points: 100, // 100 requests
duration: 60, // per 60 seconds
}),
pro: new RateLimiterRedis({
storeClient: redis,
points: 1000,
duration: 60,
}),
enterprise: new RateLimiterRedis({
storeClient: redis,
points: 10000,
duration: 60,
}),
}
export async function checkRateLimit(tenantId: string, plan: string) {
const limiter = rateLimiters[plan as keyof typeof rateLimiters]
try {
await limiter.consume(tenantId)
return { allowed: true }
} catch (error) {
return { allowed: false, retryAfter: error.msBeforeNext / 1000 }
}
}Tenant Provisioning Flow
Automate onboarding with this workflow:
export async function provisionTenant(data: {
name: string
slug: string
adminEmail: string
}) {
// 1. Create tenant record
const tenant = await prisma.tenant.create({
data: {
name: data.name,
slug: data.slug,
plan: 'free',
status: 'active',
},
})
// 2. If database-per-tenant, create database
if (TENANT_ISOLATION === 'database') {
await createTenantDatabase(tenant.id)
await runMigrations(tenant.id)
}
// 3. Create admin user
const admin = await prisma.user.create({
data: {
tenant_id: tenant.id,
email: data.adminEmail,
role: 'admin',
},
})
// 4. Send welcome email
await sendWelcomeEmail(admin.email, {
subdomain: `${tenant.slug}.yoursaas.com`,
loginUrl: `https://${tenant.slug}.yoursaas.com/login`,
})
return { tenant, admin }
}Monitoring and Observability
Track per-tenant metrics with labels:
import { Counter, Histogram } from 'prom-client'
const requestCounter = new Counter({
name: 'saas_http_requests_total',
help: 'Total HTTP requests',
labelNames: ['tenant_id', 'method', 'route', 'status'],
})
const requestDuration = new Histogram({
name: 'saas_http_request_duration_seconds',
help: 'HTTP request latency',
labelNames: ['tenant_id', 'route'],
})
// Middleware to track
export function metricsMiddleware(req, res, next) {
const start = Date.now()
const tenantId = req.headers['x-tenant-id']
res.on('finish', () => {
const duration = (Date.now() - start) / 1000
requestCounter.inc({ tenant_id: tenantId, method: req.method, route: req.route.path, status: res.statusCode })
requestDuration.observe({ tenant_id: tenantId, route: req.route.path }, duration)
})
next()
}Cost Allocation and Billing
Track usage per tenant for billing:
CREATE TABLE usage_events (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
event_type TEXT NOT NULL, -- 'api_call', 'storage_gb', 'compute_seconds'
quantity NUMERIC NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_usage_tenant_date ON usage_events(tenant_id, created_at);// Monthly usage aggregation
SELECT
tenant_id,
event_type,
SUM(quantity) as total_quantity,
DATE_TRUNC('month', created_at) as month
FROM usage_events
WHERE created_at >= NOW() - INTERVAL '30 days'
GROUP BY tenant_id, event_type, DATE_TRUNC('month', created_at);Security Best Practices
- Always validate tenant_id: Never trust client-provided tenant IDs, always derive from JWT or subdomain
- Test cross-tenant access: Write E2E tests attempting to access Tenant A data while authenticated as Tenant B
- Audit logs: Log all tenant data access with user_id, tenant_id, action, timestamp
- Data deletion: Implement GDPR-compliant tenant data purging (cascade deletes, backup removal)
- Throttling: Rate limit not just APIs, but database queries per tenant to prevent noisy neighbors
Performance at Scale
Lessons from serving 50K+ tenants:
- ✅ Connection pooling: Use PgBouncer with 50-100 connections max, even with 10K tenants
- ✅ Caching: Redis cache tenant configs (subdomain→tenant_id lookups) for 5-minute TTL
- ✅ Database indexes: Every tenant-partitioned table needs
INDEX ON (tenant_id, ...) - ✅ Query optimization: Use
EXPLAIN ANALYZEon every tenant-scoped query - ✅ Graceful degradation: If one tenant's query is slow, don't let it block others (query timeouts)
Migration Strategies
Moving from single-tenant to multitenant post-launch:
- Add tenant_id column to all tables (nullable initially)
- Create default tenant with slug "default"
- Backfill tenant_id for existing data:
UPDATE users SET tenant_id = 'default-tenant-id' - Make tenant_id NOT NULL after backfill
- Add RLS policies (test thoroughly!)
- Deploy tenant middleware with feature flag (opt-in per route initially)
- Roll out subdomain routing with fallback to original domain
Conclusion: Start Simple, Scale Strategically
Multitenant architecture isn't all-or-nothing. Start with RLS on a shared database for your first 100 customers. Migrate high-value enterprise customers to isolated databases when they demand it. Add schema-per-tenant for the middle tier.
The key is building with tenant isolation in mind from day one—even if you're not enforcing it yet. Add tenant_id to every table. Design APIs to accept tenant context. Future you will be grateful.

Written by M. Alaa Hedhly
Full-Stack Developer | ML Engineer | DevOps Engineer
I build modern web applications, ML systems, and trading automation. I also manage production infrastructure with Docker, Coolify, and Traefik.
Related Articles
Building a Production-Grade Next.js 15 + FastAPI Monorepo
Learn how to architect a scalable cross-language monorepo that combines Next.js 15 frontend with FastAPI backend, including shared types, CI/CD, and deployment strategies.
Migrating From REST to tRPC/GraphQL: What Actually Matters
A pragmatic comparison of REST, tRPC, and GraphQL based on real production migrations. Learn which API pattern fits your team and project scale.