I’ve been building SaaS applications for years, and one challenge consistently stands out: how to securely and efficiently serve multiple customers from a single application instance. That’s why I want to share my approach to creating a robust multi-tenant architecture using NestJS, Prisma, and PostgreSQL’s powerful Row Level Security feature.
What if you could build an application where each customer’s data remains completely isolated, yet you maintain a single codebase and database? This isn’t just theoretical—it’s achievable with the right architecture decisions.
Let’s start with the foundation: database design. Every table in our schema needs a tenant_id column. This simple addition becomes the cornerstone of our data isolation strategy. In Prisma, we define this relationship clearly, ensuring that every user, project, and subscription links back to a specific tenant.
model User {
id String @id @default(cuid())
email String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
// ... other fields
}
But how do we ensure that users from one tenant can never access another tenant’s data? PostgreSQL’s Row Level Security provides the answer. We create policies that automatically filter queries based on the current tenant context.
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant')::uuid);
Have you considered what happens when a user authenticates? Our JWT tokens must include the tenant context. When a user logs in, we verify their credentials and generate a token that includes their tenant identifier.
async function login(credentials: LoginDto) {
const user = await prisma.user.findUnique({
where: { email: credentials.email }
});
if (!user) throw new UnauthorizedException();
const isValid = await compare(credentials.password, user.password);
if (!isValid) throw new UnauthorizedException();
return {
access_token: this.jwtService.sign({
sub: user.id,
tenant: user.tenantId
})
};
}
In NestJS, we create a tenant-aware guard that extracts this information from the JWT and sets it in the request context. This becomes crucial for all subsequent database operations.
@Injectable()
export class TenantGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const tenantId = request.user?.tenant;
if (!tenantId) {
throw new ForbiddenException('Tenant context required');
}
// Set tenant for RLS
request.tenantId = tenantId;
return true;
}
}
What about database connections? We use Prisma’s middleware to automatically set the tenant context for every query. This ensures that even if a developer forgets to include tenant filtering, the database itself enforces isolation.
prisma.$use(async (params, next) => {
const tenantId = getCurrentTenantId(); // From request context
if (params.model && tenantId) {
if (params.action === 'findUnique' || params.action === 'findFirst') {
params.args.where.tenantId = tenantId;
}
if (params.action === 'findMany') {
params.args.where = { ...params.args.where, tenantId };
}
}
return next(params);
});
Performance is always a concern in multi-tenant systems. We create composite indexes that include both the tenant_id and commonly queried fields. This ensures that queries remain fast even as our data grows across multiple tenants.
model User {
id String @id @default(cuid())
email String
tenantId String
// ... other fields
@@index([tenantId, email])
@@index([tenantId, createdAt])
}
Subscription management becomes straightforward with this architecture. Each tenant has a subscription record that determines their access level and features. We can easily enforce limits and track usage.
async function createProject(dto: CreateProjectDto) {
const tenant = await getCurrentTenant();
const subscription = await getTenantSubscription(tenant.id);
if (subscription.plan === 'FREE' && await getProjectCount(tenant.id) >= 3) {
throw new BadRequestException('Project limit reached');
}
return prisma.project.create({
data: {
...dto,
tenantId: tenant.id
}
});
}
Testing this architecture requires careful consideration. We create test utilities that can simulate multiple tenants simultaneously, ensuring our isolation holds under various scenarios.
describe('Multi-tenant isolation', () => {
it('should prevent cross-tenant data access', async () => {
const tenantA = await createTestTenant();
const tenantB = await createTestTenant();
// Create data for tenant A
await setCurrentTenant(tenantA.id);
const projectA = await createTestProject();
// Try to access from tenant B context
await setCurrentTenant(tenantB.id);
const result = await getProject(projectA.id);
expect(result).toBeNull();
});
});
Deployment strategies vary based on scale. For most applications, starting with a single PostgreSQL instance with proper RLS policies provides excellent security while keeping costs manageable. As you grow, you might consider separate databases for your largest customers.
Monitoring becomes essential. We track query performance, tenant growth patterns, and subscription metrics. This data helps us optimize both the application and our business model.
Building a multi-tenant application requires careful planning, but the rewards are significant. You get to serve multiple customers efficiently while maintaining strong data isolation. The patterns we’ve discussed today form a solid foundation that can scale with your business.
I’d love to hear about your experiences with multi-tenant architectures. What challenges have you faced? What solutions have worked well for you? Share your thoughts in the comments below, and if you found this useful, please consider sharing it with your network.