Building robust multi-tenant SaaS applications has become increasingly vital in my work with cloud-native systems. Just last month, a client’s data isolation requirements forced me to reevaluate traditional approaches, leading me to PostgreSQL’s Row-Level Security combined with NestJS and Prisma. This combination creates a scalable foundation that maintains strict tenant separation while simplifying infrastructure. Let me walk you through how this works.
Multi-tenancy means serving multiple customers from a single application instance. Why choose shared-database RLS over alternatives? When you have hundreds of tenants, managing separate databases becomes impractical. RLS provides data isolation at the database level while keeping operational complexity low. How does this actually prevent tenant data leaks? Let’s examine the architecture.
Start with a clean NestJS setup:
nest new saas-app --strict
npm install @prisma/client prisma
npx prisma init
Our Prisma schema defines tenant-aware models. Notice the tenantId
field in every table:
model Tenant {
id String @id @default(cuid())
subdomain String @unique
users User[]
}
model User {
id String @id @default(cuid())
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
}
The real magic happens in PostgreSQL. We enable Row-Level Security and create isolation policies:
ALTER TABLE "User" ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_tenant_isolation ON "User"
FOR ALL USING (
"tenantId" = current_setting('app.current_tenant')::text
);
This ensures every query automatically filters by tenant context. But how do we set that context securely?
Authentication ties users to tenants. We implement a JWT strategy containing tenant IDs:
// auth.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET,
});
}
validate(payload: { sub: string; tenantId: string }) {
return { userId: payload.sub, tenantId: payload.tenantId };
}
}
Before executing queries, we set the tenant context using Prisma middleware:
// prisma.service.ts
this.prisma.$use(async (params, next) => {
const tenantId = getCurrentTenantId();
if (params.model === 'User') {
params.args.where = { ...params.args.where, tenantId };
}
return next(params);
});
For RLS to work, we must set the PostgreSQL session variable before each operation:
// tenant.decorator.ts
export const SetTenantContext = createParamDecorator(
(_, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const tenantId = request.user?.tenantId;
return this.prisma.$executeRaw`SELECT set_config('app.current_tenant', ${tenantId}, false)`;
}
);
Now consider performance. Without proper indexing, tenant queries become slow as data grows. Add composite indexes:
model Task {
tenantId String
projectId String
@@index([tenantId, projectId])
}
For testing, we mock tenant contexts:
// test.setup.ts
beforeEach(() => {
jest.spyOn(tenantService, 'getTenantId').mockReturnValue('test_tenant');
});
During deployment, remember these key steps:
- Enable connection pooling with PgBouncer
- Set
pool_timeout
to recycle connections - Use
SET ROLE
for RLS privileges - Enable SSL for all database connections
Common pitfalls? Forgetting to add tenantId
in every table relation tops the list. Another is neglecting to test RLS policies with direct database access. Always verify isolation by switching tenant contexts manually.
The beauty of this approach shines when onboarding new tenants:
// tenant.service.ts
async createTenant(subdomain: string) {
return this.prisma.$transaction([
prisma.tenant.create({ data: { subdomain } }),
prisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${subdomain}`
]);
}
Notice we’re using schemas for supplementary data? This hybrid approach combines RLS efficiency with schema flexibility for tenant-specific customizations.
Building SaaS applications requires thoughtful tradeoffs. By leveraging PostgreSQL’s RLS, we achieve robust data isolation without sacrificing maintainability. Prisma’s middleware keeps our code clean, while NestJS provides the architectural backbone. What other patterns have you found effective for multi-tenancy?
I’ve shared the core techniques that solved real-world isolation challenges for my clients. If this approach resonates with your projects, let me know your thoughts in the comments. Found this useful? Share it with your team facing similar SaaS architecture decisions.