I’ve been thinking a lot lately about what separates a simple application from a truly robust SaaS platform. The complexity isn’t just in the features—it’s in how you handle multiple customers securely and efficiently. That’s why I want to walk you through building a multi-tenant system with NestJS, Prisma, and PostgreSQL’s row-level security. This combination creates a foundation that scales while keeping data completely isolated between customers.
Let me show you how to set this up properly.
First, we need to establish our database schema with tenant isolation built into its core. Every table that contains tenant-specific data requires a tenantId
field. This becomes our anchor point for data separation.
model User {
id String @id @default(cuid())
email String
tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([email, tenantId])
}
But how do we ensure that users from one tenant can’t accidentally access another tenant’s data? That’s where PostgreSQL’s row-level security comes into play.
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant_id')::text);
Now, what happens when a request comes in? We need to identify which tenant it belongs to and set that context before any database operation. This is where middleware becomes crucial.
@Injectable()
export class TenantMiddleware implements NestMiddleware {
constructor(private readonly configService: ConfigService) {}
use(req: Request, res: Response, next: NextFunction) {
const tenantId = this.extractTenantFromRequest(req);
if (tenantId) {
req['tenantId'] = tenantId;
}
next();
}
}
Have you considered how this middleware integrates with your database operations? We need a Prisma client that’s aware of the current tenant context.
@Injectable()
export class TenantPrismaService {
constructor(
private readonly configService: ConfigService,
private readonly request: Request
) {}
getClient(): PrismaClient {
const tenantId = this.request['tenantId'];
const client = new PrismaClient();
// Set the tenant context for this connection
client.$executeRaw`SELECT set_config('app.current_tenant_id', ${tenantId}, true)`;
return client;
}
}
What about authentication and authorization? We need guards that understand multi-tenancy.
@Injectable()
export class TenantGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const userTenantId = request.user.tenantId;
const requestTenantId = request.tenantId;
return userTenantId === requestTenantId;
}
}
Testing becomes particularly important in a multi-tenant environment. You need to verify that data isolation works as expected.
describe('Multi-tenant Data Isolation', () => {
it('should not allow cross-tenant data access', async () => {
const tenantAClient = await createTestClient('tenant-a');
const tenantBClient = await createTestClient('tenant-b');
// Create data in tenant A
await tenantAClient.user.create({ data: { email: '[email protected]' } });
// Try to access from tenant B
const result = await tenantBClient.user.findMany();
expect(result).toHaveLength(0);
});
});
Performance considerations are different in multi-tenant systems. Database indexes need special attention.
model User {
id String @id @default(cuid())
email String
tenantId String
@@index([tenantId])
@@index([email, tenantId])
}
What about database migrations? They need to handle both schema changes and RLS policies.
async function runMigrations() {
// Schema migrations
await prisma.$executeRaw`ALTER TABLE users ADD COLUMN IF NOT EXISTS new_column TEXT`;
// RLS policy updates
await prisma.$executeRaw`
CREATE POLICY IF NOT EXISTS user_select_policy ON users
FOR SELECT USING (tenant_id = current_setting('app.current_tenant_id')::text)
`;
}
Error handling requires special consideration too. You don’t want to leak tenant information in error messages.
@Catch(PrismaClientKnownRequestError)
export class PrismaClientExceptionFilter implements ExceptionFilter {
catch(exception: PrismaClientKnownRequestError, host: ArgumentsHost) {
const response = host.switchToHttp().getResponse();
// Sanitize error messages to remove tenant-specific information
const safeMessage = this.sanitizeErrorMessage(exception.message);
response.status(500).json({
error: 'Database operation failed',
message: safeMessage
});
}
}
Building a multi-tenant application requires thinking about every layer of your stack. From database design to API endpoints, each component must respect tenant boundaries. The patterns I’ve shown here provide a solid foundation, but remember that every application has unique requirements.
What challenges have you faced with multi-tenancy? I’d love to hear about your experiences and solutions. If you found this helpful, please share it with others who might benefit from these patterns. Your comments and questions help make these guides better for everyone.