Recently, I was working on a new SaaS project and realized how crucial it is to build a system that can securely serve multiple customers without mixing their data. This led me to explore combining NestJS, Prisma, and PostgreSQL’s Row-Level Security for a robust multi-tenant architecture. If you’re building a SaaS application, this approach can save you from reinventing the wheel while ensuring data isolation and scalability.
Multi-tenancy means a single application serves multiple clients, keeping their data separate. I prefer the shared schema method with Row-Level Security because it balances cost and performance. Did you know that without proper isolation, a simple query could expose one tenant’s data to another? That’s why RLS is a game-changer—it enforces security at the database level.
Setting up the project starts with initializing NestJS and Prisma. I use a structured folder layout to keep things organized. Here’s a quick setup:
npx @nestjs/cli new saas-app --skip-git
npx prisma init
npm install @prisma/client @nestjs/jwt passport-jwt bcryptjs
For the database, PostgreSQL with RLS is key. In your Prisma schema, define models with tenant IDs. Then, enable RLS and create policies to restrict access based on the current tenant. This code sets up a basic tenant and user model:
model Tenant {
id String @id @default(cuid())
name String
subdomain String @unique
users User[]
}
model User {
id String @id @default(cuid())
email String @unique
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
}
After migrating, add RLS policies in SQL:
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY user_isolation ON users USING (tenant_id = current_setting('app.tenant_id'));
How do we make Prisma tenant-aware? I extended the Prisma client to handle tenant context. This service sets the tenant ID for each request, ensuring all queries are scoped:
@Injectable()
export class PrismaService extends PrismaClient {
async setTenant(tenantId: string) {
await this.$executeRaw`SELECT set_config('app.tenant_id', ${tenantId}, true)`;
}
}
In NestJS, I use middleware to extract the tenant from the request—say, from a subdomain or JWT token. This middleware calls setTenant before passing control to the route handler. What happens if the tenant isn’t found? I handle that with a guard that returns a 403 error.
Authentication needs to be tenant-specific. I implement a JWT strategy that includes the tenant ID in the token. When a user logs in, I verify they belong to the correct tenant. Here’s a snippet for a tenant guard:
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const tenantId = request.user.tenantId;
if (!tenantId) throw new ForbiddenException('Invalid tenant');
return true;
}
}
For services, I inject the Prisma service and ensure all database operations use the set tenant context. This way, every query automatically respects RLS. Have you considered how to handle migrations in a multi-tenant setup? I keep it simple by applying schema changes globally, as all tenants share the same structure.
Performance is critical. I use connection pooling and index tenant IDs to speed up queries. Also, caching tenant-specific data can reduce database load. But remember, always test with multiple tenants to catch isolation issues early.
In my experience, this setup scales well for hundreds of tenants. However, if you expect massive growth, consider separating databases later. The key is starting with a solid foundation.
I’d love to hear your thoughts—have you tried similar approaches, or faced challenges with multi-tenancy? Share your experiences in the comments below, and if this guide helped, please like and share it with others who might benefit!