Building Multi-Tenant SaaS with NestJS: Architecture Guide
Why Multi-Tenancy Matters
Every B2B SaaS is multi-tenant. Your customers (tenants) share your infrastructure but expect their data to be isolated. Get this wrong and you leak data between customers. Get it right and you scale efficiently without deploying separate instances for each client.
There are three approaches, each with different trade-offs.
Approach 1: Shared Database, Shared Schema
All tenants share one database and one set of tables. Every row has a tenantId column, and every query filters by it.
// Every repository method includes the tenant filter
@Injectable()
export class ProjectService {
async findAll(tenantId: string) {
return this.projectRepo.find({ where: { tenantId } });
}
async create(tenantId: string, dto: CreateProjectDto) {
const project = this.projectRepo.create({ ...dto, tenantId });
return this.projectRepo.save(project);
}
}
Pros:
- Simplest to implement
- One database to manage, back up, and migrate
- Lowest infrastructure cost
- Easy cross-tenant analytics (for you, the SaaS operator)
Cons:
- One missed
WHERE tenantId = ?leaks data - Noisy neighbor problem — one tenant's heavy queries affect everyone
- Hard to offer tenant-specific customizations
- Compliance concerns for regulated industries
Best for: Early-stage SaaS with < 100 tenants and no strict data isolation requirements.
Cloudrix SaaS Starter ships with this pre-configured and tested.
Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.
Try the live demo →Approach 2: Shared Database, Separate Schemas
Each tenant gets their own database schema (namespace) within a shared database. Tables are identical but isolated by schema.
// Middleware sets the schema per request
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private dataSource: DataSource) {}
async use(req: Request, res: Response, next: NextFunction) {
const tenantId = this.extractTenantId(req);
await this.dataSource.query(`SET search_path TO tenant_${tenantId}`);
next();
}
}
Pros:
- Strong isolation without multiple databases
- No risk of forgetting tenant filters — the schema handles it
- Easy to drop a tenant's data (drop the schema)
- Moderate infrastructure cost
Cons:
- Schema migrations run per-tenant — 500 tenants means 500 migration runs
- Connection pooling gets complex
- PostgreSQL-specific (
SET search_path) — not portable to all databases - Tenant provisioning requires DDL operations
Best for: Mid-stage SaaS that needs isolation but doesn't want multiple database servers.
Approach 3: Separate Databases
Each tenant gets their own database instance. Complete isolation at the infrastructure level.
// Connection manager maintains a pool per tenant
@Injectable()
export class TenantConnectionManager {
private connections = new Map<string, DataSource>();
async getConnection(tenantId: string): Promise<DataSource> {
if (this.connections.has(tenantId)) {
return this.connections.get(tenantId);
}
const config = await this.getTenantDbConfig(tenantId);
const dataSource = new DataSource(config);
await dataSource.initialize();
this.connections.set(tenantId, dataSource);
return dataSource;
}
}
Pros:
- Maximum isolation — no possibility of data leakage
- Per-tenant performance tuning and scaling
- Easy compliance with data residency requirements
- Independent backup and restore per tenant
Cons:
- Highest infrastructure cost
- Complex connection management
- Migrations must run against every database
- Cross-tenant reporting requires aggregation layer
Best for: Enterprise SaaS with strict compliance requirements, high-value tenants, or data residency mandates.
What SaaS Starter Uses (and Why)
SaaS Starter implements Approach 1 (shared database, shared schema) with a robust tenant scoping layer. Here's why:
Most SaaS products start here. Premature isolation adds complexity without benefit until you have enterprise customers demanding it.
NestJS makes it safe. A request-scoped
TenantContextservice extracts the tenant ID from the JWT token. A global interceptor ensures every database query is automatically scoped:
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
private tenantId: string;
setTenantId(id: string) { this.tenantId = id; }
getTenantId(): string { return this.tenantId; }
}
Upgrade path is clear. When a customer needs schema-level isolation, you migrate their data to a separate schema without changing your application code. The tenant context layer abstracts the storage strategy.
Global interceptor prevents leaks. Unlike manually adding
WHERE tenantId = ?to every query, the interceptor applies tenant scoping at the repository level. Developers can't accidentally forget it.
Implementing Tenant Scoping in NestJS
The pattern is straightforward:
Extract tenant from JWT — The auth token includes the tenant ID. A guard parses it and sets the
TenantContext.Scope all queries — A base repository class adds the tenant filter automatically:
export class TenantAwareRepository<T> extends Repository<T> {
constructor(private tenantContext: TenantContext) {
super();
}
find(options?: FindManyOptions<T>): Promise<T[]> {
return super.find({
...options,
where: {
...options?.where,
tenantId: this.tenantContext.getTenantId(),
},
});
}
}
Validate on write — Before creating or updating records, verify the tenant ID matches the authenticated user's tenant.
Test the boundaries — Write integration tests that attempt cross-tenant access and verify they fail.
Common Mistakes
Learn how SaaS Starter solves multi-tenancy out of the box on the multi-tenancy feature page.
- Forgetting tenant scope on joins — When joining tables, both sides need tenant filtering.
- Admin endpoints bypassing scope — Super-admin routes should explicitly opt out of tenant scoping, not accidentally skip it.
- Caching without tenant keys — If you cache query results, include the tenant ID in the cache key.
- Background jobs losing context — Jobs run outside the request scope. Pass the tenant ID explicitly in the job payload.
Get Started Today
All of these patterns are fully implemented in SaaS Starter. Skip 2-6 weeks of setup — auth, payments, multi-tenancy, deployment all done.
- Free version: GitHub
- Pro ($249): Get SaaS Starter Pro