I’ve been developing SaaS applications for several years now, and one challenge that consistently arises is securely serving multiple customers from a single codebase. Just last month, while architecting a new B2B platform, I faced this exact problem - how to efficiently isolate tenant data without compromising performance or maintainability. This led me to explore NestJS combined with PostgreSQL’s Row-Level Security, and I want to share what I learned. If you’re building scalable SaaS products, you’ll find these patterns invaluable for balancing security with operational efficiency.
Starting a multi-tenant project requires careful setup. I began with a fresh NestJS installation and added essential packages: Prisma for database interactions, authentication tools, and security utilities. The project structure organizes functionality by domain - authentication, tenant management, and business logic sit in separate modules. This separation proves crucial when the application grows. Have you considered how your folder structure affects long-term maintenance?
For database design, I chose a shared schema approach with explicit tenant IDs in each table. Using Prisma, I defined models with tenant relationships - every user and organization links to a specific tenant. The real magic happens in PostgreSQL policies. By enabling Row-Level Security and creating isolation policies, we ensure queries automatically filter by tenant. For example:
CREATE POLICY "tenant_users_isolation" ON "users"
FOR ALL
USING ("tenantId" = current_setting('app.current_tenant_id', true));
This policy restricts all user operations to the current tenant context. But how do we set that context securely?
In the application layer, I extended PrismaClient to handle tenant contexts. The key method executes operations within a tenant-specific boundary:
async withTenant<T>(tenantId: string, operation: () => Promise<T>): Promise<T> {
await this.setTenantContext(tenantId);
try {
return await operation();
} finally {
await this.clearTenantContext();
}
}
An interceptor then automatically applies this context to requests. When a user authenticates, we extract their tenant ID from JWT claims and attach it to the request. Every subsequent database call inherits this context. What would happen if we forgot to clear the context between requests?
Authentication deserves special attention. I implemented a passport strategy that validates JWTs while extracting tenant information:
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
});
}
async validate(payload: any) {
return {
userId: payload.sub,
tenantId: payload.tenantId // Critical for RLS
};
}
}
This tenant ID propagates through guards and decorators to our services. For complex cases like users belonging to multiple organizations within a tenant, I added additional permission checks at the service layer.
Testing requires special consideration. I configure a separate test tenant and use transactions to isolate test cases:
beforeEach(async () => {
await prisma.$transaction(async (tx) => {
await tx.user.deleteMany();
testUser = await tx.user.create({ /* ... */ });
});
});
This prevents test pollution while maintaining RLS validity.
Performance optimization proved interesting. I added indexes on tenantId columns and avoided N+1 queries using Prisma’s relation loading. For large datasets, partitioning by tenant ID could be beneficial. One mistake I made early was forgetting to validate tenant ownership when accessing nested resources - a lesson learned the hard way!
Building multi-tenant systems requires thoughtful tradeoffs between isolation and efficiency. The NestJS-Prisma-RLS combination provides a robust foundation that scales elegantly. What challenges have you faced in multi-tenant architectures? Share your experiences below - I’d love to hear different approaches. If this helped you, please like and share so others can benefit too!