How to Build a Multi-Tenant Backend with NestJS
A practical guide to building multi-tenant backends with NestJS, PostgreSQL, and Prisma. Real patterns, schema design, and lessons from production.
What is Multi-Tenancy?
Multi-tenancy means one application serves multiple independent clients (tenants) — each with their own isolated data.
Think of it like an apartment building:
One building (your app)
├── Apartment 101 (Tenant A — their data)
├── Apartment 102 (Tenant B — their data)
└── Apartment 103 (Tenant C — their data)
Each tenant shares the same infrastructure but never sees each other's data.
Common examples:
SaaS platforms (Slack, Notion, Jira)
Agency dashboards managing multiple clients
Notification systems serving multiple businesses
Three Approaches to Multi-Tenancy
Before writing code, you need to pick the right isolation strategy:
| Approach | How It Works | Best For |
|---|---|---|
| Separate databases | One DB per tenant | High security, enterprise clients |
| Separate schemas | One schema per tenant in same DB | Medium isolation, mid-size SaaS |
| Shared schema | All tenants in same tables with tenantId |
Startups, cost-effective scaling |
I use shared schema with tenantId for most freelance projects. It's the most practical approach — easy to implement, cost-effective, and scales well up to millions of rows.
This is what we'll build today.
Database Schema Design
The foundation of a multi-tenant system is getting the schema right.
// prisma/schema.prisma
model Tenant {
id String @id @default(cuid())
name String
slug String @unique // used for subdomain: tenant.yoursaas.com
plan Plan @default(FREE)
isActive Boolean @default(true)
createdAt DateTime @default(now())
users User[]
products Product[]
orders Order[]
subscription Subscription?
@@index([slug])
}
model User {
id String @id @default(cuid())
email String
name String
password String
role UserRole @default(MEMBER)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
createdAt DateTime @default(now())
// Email unique PER tenant, not globally
@@unique([email, tenantId])
@@index([tenantId])
}
model Product {
id String @id @default(cuid())
name String
description String?
price Float
stock Int @default(0)
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
createdAt DateTime @default(now())
@@index([tenantId])
@@index([tenantId, createdAt(sort: Desc)])
}
model Order {
id String @id @default(cuid())
status OrderStatus @default(PENDING)
total Float
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
createdAt DateTime @default(now())
@@index([tenantId, status])
@@index([tenantId, createdAt(sort: Desc)])
}
enum Plan {
FREE
PRO
ENTERPRISE
}
enum UserRole {
OWNER
ADMIN
MEMBER
}
enum OrderStatus {
PENDING
CONFIRMED
SHIPPED
DELIVERED
CANCELLED
}
Key design decisions:
Every table has
tenantId— this is the core isolation mechanism@@index([tenantId])on every table — critical for query performanceEmail is unique per tenant, not globally —
user@gmail.comcan exist in multiple tenantsComposite indexes on common query patterns —
[tenantId, createdAt],[tenantId, status]
Tenant Resolution — How Does the App Know Which Tenant?
Before any request is processed, the app needs to know which tenant is making it.
Three common strategies:
1. Subdomain → tenant-a.yoursaas.com
2. Header → X-Tenant-ID: tenant_123
3. JWT claim → { sub: userId, tenantId: tenant_123 }
I use JWT claim for API-first SaaS and subdomain for white-label products. Here's the JWT approach:
// src/common/decorators/tenant.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentTenant = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.tenant;
},
);
// src/common/guards/tenant.guard.ts
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class TenantGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private prisma: PrismaService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.split(' ')[1];
if (!token) throw new UnauthorizedException('No token provided');
const payload = this.jwtService.verify(token);
// Fetch tenant and attach to request
const tenant = await this.prisma.tenant.findUnique({
where: { id: payload.tenantId },
});
if (!tenant || !tenant.isActive) {
throw new NotFoundException('Tenant not found or inactive');
}
request.tenant = tenant;
request.user = payload;
return true;
}
}
The Most Important Rule — Always Filter by tenantId
This is where most developers make mistakes. Every single database query must filter by tenantId.
// src/modules/products/products.service.ts
@Injectable()
export class ProductsService {
constructor(private prisma: PrismaService) {}
// ❌ WRONG — returns products from ALL tenants
async findAll() {
return this.prisma.product.findMany();
}
// ✅ CORRECT — scoped to tenant
async findAll(tenantId: string) {
return this.prisma.product.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
});
}
// ❌ WRONG — could return another tenant's product
async findOne(id: string) {
return this.prisma.product.findUnique({
where: { id },
});
}
// ✅ CORRECT — validates both id AND tenantId
async findOne(id: string, tenantId: string) {
const product = await this.prisma.product.findFirst({
where: { id, tenantId },
});
if (!product) throw new NotFoundException('Product not found');
return product;
}
}
Missing a tenantId filter on a single query is a data breach. Tenant A could see Tenant B's data. Never skip it.
Tenant-Scoped Controller
// src/modules/products/products.controller.ts
@Controller('products')
@UseGuards(TenantGuard)
export class ProductsController {
constructor(private productsService: ProductsService) {}
@Get()
findAll(@CurrentTenant() tenant: Tenant) {
return this.productsService.findAll(tenant.id);
}
@Get(':id')
findOne(@Param('id') id: string, @CurrentTenant() tenant: Tenant) {
return this.productsService.findOne(id, tenant.id);
}
@Post()
create(@Body() dto: CreateProductDto, @CurrentTenant() tenant: Tenant) {
return this.productsService.create(dto, tenant.id);
}
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateProductDto,
@CurrentTenant() tenant: Tenant,
) {
return this.productsService.update(id, dto, tenant.id);
}
@Delete(':id')
remove(@Param('id') id: string, @CurrentTenant() tenant: Tenant) {
return this.productsService.remove(id, tenant.id);
}
}
Tenant Registration Flow
// src/modules/tenant/tenant.service.ts
@Injectable()
export class TenantService {
constructor(private prisma: PrismaService) {}
async register(dto: RegisterTenantDto) {
// Check slug availability
const exists = await this.prisma.tenant.findUnique({
where: { slug: dto.slug },
});
if (exists) throw new ConflictException('Slug already taken');
// Create tenant + owner in one transaction
const result = await this.prisma.$transaction(async (tx) => {
const tenant = await tx.tenant.create({
data: {
name: dto.businessName,
slug: dto.slug,
},
});
const hashedPassword = await bcrypt.hash(dto.password, 12);
const owner = await tx.user.create({
data: {
email: dto.email,
name: dto.name,
password: hashedPassword,
role: 'OWNER',
tenantId: tenant.id,
},
});
return { tenant, owner };
});
const token = this.generateToken(result.owner.id, result.tenant.id);
return {
tenant: result.tenant,
user: { id: result.owner.id, email: result.owner.email },
token,
};
}
private generateToken(userId: string, tenantId: string) {
return this.jwtService.sign({ sub: userId, tenantId });
}
}
Plan-Based Feature Gating
Different tenants on different plans get different features:
// src/common/guards/plan.guard.ts
import { SetMetadata } from '@nestjs/common';
export const RequiredPlan = (...plans: Plan[]) =>
SetMetadata('plans', plans);
@Injectable()
export class PlanGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const requiredPlans = this.reflector.get<Plan[]>('plans', context.getHandler());
if (!requiredPlans) return true;
const request = context.switchToHttp().getRequest();
const tenant = request.tenant;
if (!requiredPlans.includes(tenant.plan)) {
throw new ForbiddenException(
`This feature requires ${requiredPlans.join(' or ')} plan`
);
}
return true;
}
}
// Usage on any endpoint
@Post('bulk-export')
@RequiredPlan(Plan.PRO, Plan.ENTERPRISE)
@UseGuards(TenantGuard, PlanGuard)
async bulkExport(@CurrentTenant() tenant: Tenant) {
return this.exportService.bulkExport(tenant.id);
}
Performance at Scale
As your tenant count grows, these optimizations become critical:
1. Composite Indexes on Every Table
-- Every tenant-scoped query benefits from this
CREATE INDEX idx_products_tenant_created
ON products(tenant_id, created_at DESC);
CREATE INDEX idx_orders_tenant_status
ON orders(tenant_id, status);
2. Connection Pooling with PgBouncer
Without PgBouncer:
100 tenants × 10 concurrent users = 1,000 DB connections
With PgBouncer:
1,000 app connections → 50 actual DB connections
3. Redis Caching for Tenant Lookups
async getTenant(tenantId: string) {
const cacheKey = `tenant:${tenantId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const tenant = await this.prisma.tenant.findUnique({
where: { id: tenantId },
});
// Cache for 5 minutes — tenant data rarely changes
await this.redis.setex(cacheKey, 300, JSON.stringify(tenant));
return tenant;
}
Common Mistakes to Avoid
❌ Missing tenantId filter on any query → data breach
❌ Storing tenantId only in JWT without validating in DB → security risk
❌ No indexes on tenantId columns → slow queries at scale
❌ Single database connection pool for all tenants → bottleneck
❌ Hardcoding plan limits in code → painful to update
✅ Always filter by tenantId
✅ Validate tenant exists and is active on every request
✅ Index every tenantId column
✅ Store plan limits in database — not code
Real World Application
I used this exact multi-tenant pattern to build the backend for The Design Ethos — a Flutter-based platform where multiple organizations manage their own users, content, and settings independently.
The same architecture also powers the notification engine I described in my post on building a dynamic notification engine with BullMQ and Redis — where each tenant configures their own templates and providers.
Summary
Multi-tenancy with shared schema comes down to three things:
1. Every table has tenantId
2. Every query filters by tenantId
3. Every request resolves the tenant first
Get those three right and the rest is standard NestJS development.
Need a multi-tenant backend built for your SaaS? Let's discuss your project — I've built this architecture in production and can deliver a production-ready system for your business.
Have questions about the implementation? Drop a comment below.
