I’ve been thinking about multi-tenancy a lot lately. Building SaaS platforms that serve multiple customers securely and efficiently requires careful architectural decisions. Today, I’ll share how to create a robust multi-tenant API using NestJS, Prisma, and PostgreSQL’s Row-Level Security. This approach ensures strong data isolation while keeping costs manageable. Why settle for less when you can build enterprise-grade solutions?
Let’s start with architecture choices. Multiple approaches exist for tenant isolation. Separate databases offer strong separation but increase operational overhead. Schema-based isolation provides middle ground. But PostgreSQL’s Row-Level Security delivers excellent isolation with simpler management. Have you considered how RLS might simplify your data partitioning? It allows all tenants to coexist in one database while maintaining strict boundaries through database policies.
Setting up our NestJS project is straightforward. We begin with standard initialization:
nest new multitenant-saas-api
cd multitenant-saas-api
npm install @prisma/client prisma @nestjs/config
Our project structure organizes functionality into clear modules:
- Authentication handles tenant-scoped logins
- Tenant management tracks customer organizations
- Domain modules like Users and Projects contain business logic
- Database layer integrates Prisma with RLS
Now, the database schema. Using Prisma, we model our multi-tenant relationships:
model Tenant {
id String @id @default(uuid())
slug String @unique
name String
}
model User {
id String @id @default(uuid())
email String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([email, tenantId])
}
Notice the tenantId
field on every tenant-scoped model. This becomes our isolation anchor. But how do we enforce that tenants only access their own data? That’s where PostgreSQL RLS shines.
Let’s implement Row-Level Security policies:
CREATE POLICY tenant_isolation ON users
FOR ALL TO PUBLIC
USING (tenant_id = current_setting('app.current_tenant_id'));
This policy ensures users can only access records where tenant_id
matches their session context. We create similar policies for all tenant-specific tables. The magic happens through session variables set per request. But how do we integrate this with our application?
Our Prisma service handles tenant context propagation:
// src/database/prisma.service.ts
@Injectable()
export class PrismaService extends PrismaClient {
async withTenant(tenantId: string) {
return this.$extends({
query: {
async $allOperations({ query, args }) {
await this.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
return query(args);
}
}
});
}
}
This extension sets the tenant context before each database operation. Notice how we use PostgreSQL’s set_config
to establish session-level isolation. Now consider this: what happens when we need to perform cross-tenant operations? We implement special service roles with policy exemptions:
CREATE POLICY bypass_rls ON users
FOR ALL TO service_role
USING (true);
For our business logic, we create tenant-aware services:
// src/database/tenant-aware.service.ts
@Injectable({ scope: Scope.REQUEST })
export class TenantAwareService {
constructor(
private prisma: PrismaService,
@Inject(REQUEST) private request: TenantRequest
) {}
async getProjects() {
const tenantClient = await this.prisma.withTenant(this.request.tenant.id);
return tenantClient.project.findMany();
}
}
By binding to the request scope, we automatically isolate data per tenant. The withTenant
method creates a Prisma client instance scoped to the current tenant. This pattern works beautifully with NestJS’s dependency injection.
Authentication needs special consideration. We implement JWT strategies that include tenant context:
// src/auth/jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: { sub: string; tenant: string }) {
return { userId: payload.sub, tenantId: payload.tenant };
}
}
The JWT payload includes both user and tenant identifiers. This allows our guards to establish complete request context. What security measures would you add to prevent tenant impersonation?
Data migrations require careful handling in multi-tenant systems. We version our schema changes and apply them progressively:
prisma migrate dev --name add_project_status
Prisma’s migration system handles schema updates while maintaining RLS policies. For large-scale changes, we script gradual rollouts using tenant metadata.
Performance optimization is crucial. We always include tenant_id
in indexes:
model Project {
id String @id @default(uuid())
tenantId String
@@index([tenantId])
}
Composite indexes combining tenant_id
with frequently queried fields dramatically improve performance. Have you measured how indexes affect your query latency?
Building multi-tenant systems challenges us to balance security and efficiency. By leveraging PostgreSQL’s RLS with NestJS’s modular architecture and Prisma’s type safety, we create robust SaaS platforms. The patterns we’ve explored today provide a foundation you can adapt to various domains. What tenant isolation challenges have you encountered in your projects?
If you found this guide useful, please share it with your network. I’d love to hear about your implementation experiences in the comments below.