Multi-tenant SaaS architecture is the foundation of scalable software-as-a-service applications. Whether you're building a B2B SaaS serving hundreds of companies or a platform supporting thousands of users, understanding multi-tenancy is crucial for creating efficient, secure, and maintainable applications. This comprehensive guide will walk you through implementing multi-tenant architecture in Next.js, covering database strategies, security patterns, and performance optimization.
What is multi-tenancy?
Multi-tenancy is an architecture pattern where a single instance of your application serves multiple customers (tenants). Each tenant's data is isolated and remains invisible to other tenants, even though they share the same infrastructure, database, and application code.
Think of it like an apartment building: multiple tenants live in the same building (shared infrastructure), but each has their own apartment (isolated data) with locks on the doors (security). In SaaS terms, this means:
- Shared infrastructure: One application instance serves all tenants
- Data isolation: Each tenant can only access their own data
- Cost efficiency: Resources are shared, reducing infrastructure costs
- Easier maintenance: One codebase to maintain and deploy
Multi-tenant architecture patterns
There are three primary approaches to multi-tenant architecture, each with different trade-offs:
1. Database per tenant
Each tenant has their own dedicated database. This provides the strongest isolation but requires more resources and complexity.
2. Shared database, separate schemas
All tenants share the same database, but each has their own schema (namespace). This balances isolation with resource efficiency.
3. Shared database, row-level security
All tenants share the same database and schema, with a tenantId column used to filter data. This is the most resource-efficient approach.
Implementing row-level security in Next.js
For most SaaS applications, the shared database with row-level security approach offers the best balance of simplicity, cost, and scalability. Let's implement this pattern in Next.js with Prisma.
Step 1: Add tenant context to your schema
First, extend your Prisma schema to include a tenantId field in all tenant-scoped models:
model Tenant {
id String @id @default(cuid())
name String
subdomain String? @unique // Optional: for subdomain-based routing
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
projects Project[]
}
model User {
id String @id @default(cuid())
email String
name String?
tenantId String // Tenant association
role String @default("member") // "owner", "admin", "member"
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
projects Project[]
@@unique([email, tenantId]) // Email unique per tenant
@@index([tenantId])
}
model Project {
id String @id @default(cuid())
name String
description String?
tenantId String // Tenant association
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
owner User @relation(fields: [ownerId], references: [id])
ownerId String
@@index([tenantId])
@@index([ownerId])
}Step 2: Create tenant context middleware
Create a middleware to extract and validate the tenant from the request. This can be done via subdomain, custom header, or JWT token:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Extract tenant from subdomain
const hostname = request.headers.get('host') || '';
const subdomain = hostname.split('.')[0];
// Or extract from custom header
const tenantId = request.headers.get('x-tenant-id');
// Or extract from JWT token (if using NextAuth)
// const session = await getServerSession();
// const tenantId = session?.user?.tenantId;
// Add tenant to request headers for use in server components
const requestHeaders = new Headers(request.headers);
if (tenantId) {
requestHeaders.set('x-tenant-id', tenantId);
}
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
export const config = {
matcher: [
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};Step 3: Create tenant-scoped Prisma helper
Create a helper function that automatically filters queries by tenant ID:
import { PrismaClient } from '@prisma/client';
import { getPrismaClient } from './client';
export function getTenantScopedClient(tenantId: string) {
const prisma = getPrismaClient();
// Return a proxy that automatically adds tenantId to queries
return new Proxy(prisma, {
get(target, prop) {
const original = target[prop as keyof PrismaClient];
if (typeof original === 'function' && prop !== '$connect' && prop !== '$disconnect') {
return function (this: any, ...args: any[]) {
// For findMany, findFirst, etc., add tenantId filter
if (['findMany', 'findFirst', 'findUnique', 'count', 'aggregate'].includes(prop as string)) {
const [params] = args;
if (params?.where) {
params.where = {
...params.where,
tenantId,
};
} else {
params.where = { tenantId };
}
}
// For create, update, delete, ensure tenantId is set
if (['create', 'update', 'updateMany', 'delete', 'deleteMany'].includes(prop as string)) {
const [params] = args;
if (params?.data) {
params.data = {
...params.data,
tenantId,
};
}
}
return original.apply(this, args);
};
}
return original;
},
});
}
// Alternative: Simpler approach with explicit tenant filtering
export function withTenant<T>(
tenantId: string,
query: (prisma: PrismaClient) => Promise<T>
): Promise<T> {
const prisma = getPrismaClient();
// Set tenant context on Prisma client
// This requires custom Prisma extension or middleware
return query(prisma);
}Step 4: Use tenant context in server components
In your server components and API routes, extract the tenant ID and use it for all database queries:
import { headers } from 'next/headers';
import { prisma } from '@/lib/db';
// Tenant context from headers or middleware (app-defined)
// const getTenantScopedClient = (tenantId: string) => prisma; // or app-specific scoping
export default async function ProjectsPage() {
const headersList = await headers();
const tenantId = headersList.get('x-tenant-id');
if (!tenantId) {
throw new Error('Tenant ID is required');
}
// Use app's Prisma client (tenant filtering in where clauses as needed)
const db = prisma;
const projects = await db.project.findMany({
include: {
owner: true,
},
});
// Option 2: Explicitly filter by tenantId
// const prisma = getPrismaClient();
// const projects = await prisma.project.findMany({
// where: {
// tenantId,
// },
// include: {
// owner: true,
// },
// });
return (
<div>
<h1>Projects</h1>
{projects.map((project) => (
<div key={project.id}>
<h2>{project.name}</h2>
<p>{project.description}</p>
</div>
))}
</div>
);
}Security best practices
Security is critical in multi-tenant applications. Here are essential practices to prevent data leakage:
1. Always validate tenant access
Never trust client-provided tenant IDs. Always validate that the authenticated user belongs to the tenant they're requesting:
async function validateTenantAccess(userId: string, tenantId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { tenantId: true },
});
if (!user || user.tenantId !== tenantId) {
throw new Error('Unauthorized: User does not belong to this tenant');
}
return true;
}2. Use database row-level security (RLS)
For PostgreSQL, enable Row-Level Security policies to enforce tenant isolation at the database level:
-- Enable RLS on tables
ALTER TABLE "Project" ENABLE ROW LEVEL SECURITY;
-- Create policy that filters by tenant_id
CREATE POLICY tenant_isolation_policy ON "Project"
USING (tenant_id = current_setting('app.current_tenant_id')::text);
-- Set tenant context before queries
SET app.current_tenant_id = 'tenant-123';
SELECT * FROM "Project"; -- Only returns projects for tenant-1233. Implement tenant-aware middleware
Create middleware that automatically validates tenant access for all requests:
export async function withTenantAuth<T>(
request: Request,
handler: (tenantId: string, userId: string) => Promise<T>
) {
const session = await getServerSession();
if (!session?.user) {
throw new Error('Unauthorized');
}
const tenantId = request.headers.get('x-tenant-id');
if (!tenantId) {
throw new Error('Tenant ID required');
}
// Validate user belongs to tenant
await validateTenantAccess(session.user.id, tenantId);
return handler(tenantId, session.user.id);
}Performance optimization
Multi-tenant applications require careful performance optimization to handle scale:
1. Index tenant columns
Always index tenantId columns and composite indexes for common query patterns:
model Project {
id String @id @default(cuid())
tenantId String
ownerId String
status String
@@index([tenantId])
@@index([tenantId, status]) // Composite index for filtered queries
@@index([tenantId, ownerId]) // Composite index for user's projects
}2. Implement connection pooling
Use Prisma connection pooling to efficiently handle multiple tenant queries:
// Use Prisma Data Proxy or PgBouncer for connection pooling
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL, // Use pooled connection string
},
},
});3. Cache tenant metadata
Cache frequently accessed tenant information to reduce database queries:
import { cache } from 'react';
export const getTenant = cache(async (tenantId: string) => {
// React cache ensures this is only called once per request
return await prisma.tenant.findUnique({
where: { id: tenantId },
select: {
id: true,
name: true,
subdomain: true,
},
});
});Subdomain-based tenant routing
For a better user experience, you can route tenants by subdomain (e.g., acme.yoursaas.com):
// middleware.ts
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') || '';
const parts = hostname.split('.');
// Extract subdomain (first part before main domain)
const subdomain = parts.length > 2 ? parts[0] : null;
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
// Look up tenant by subdomain
const tenant = await prisma.tenant.findUnique({
where: { subdomain },
});
if (tenant) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-tenant-id', tenant.id);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
}
}
// Default tenant or redirect to main app
return NextResponse.redirect(new URL('/select-tenant', request.url));
}Migration strategy
When adding multi-tenancy to an existing application, follow these steps:
- Create Tenant model: Add a Tenant table to your database
- Add tenantId to existing models: Migrate existing data to a default tenant
- Update queries: Add tenantId filters to all queries
- Add middleware: Implement tenant context extraction
- Test thoroughly: Verify data isolation with multiple tenants
// Migration: Add tenant support
// 1. Create default tenant
const defaultTenant = await prisma.tenant.create({
data: {
name: 'Default Tenant',
subdomain: 'default',
},
});
// 2. Add tenantId to existing users
await prisma.user.updateMany({
data: {
tenantId: defaultTenant.id,
},
});
// 3. Add tenantId to other models
await prisma.$executeRaw`
ALTER TABLE "Project" ADD COLUMN "tenantId" TEXT;
UPDATE "Project" SET "tenantId" = $1;
ALTER TABLE "Project" ALTER COLUMN "tenantId" SET NOT NULL;
`, defaultTenant.id);Conclusion
Multi-tenant architecture is essential for building scalable SaaS applications. By implementing row-level security with tenant IDs, you can efficiently serve thousands of customers while maintaining data isolation and security. The key principles are:
- Always filter queries by tenantId
- Validate tenant access on every request
- Use database indexes for performance
- Implement proper connection pooling
- Consider row-level security for additional protection
Start with the shared database approach and evolve to more complex patterns as your needs grow. With Next.js and Prisma, implementing multi-tenancy is straightforward and maintainable.
Ready to build your SaaS faster?
Get our scalable, production-ready boilerplate to save endless hours of development and setup
