js

Complete Multi-Tenant SaaS Guide: NestJS, Prisma & PostgreSQL with Database-per-Tenant Architecture

Learn to build scalable multi-tenant SaaS apps with NestJS, Prisma & PostgreSQL. Master database isolation, dynamic connections & tenant security. Complete guide with code examples.

Complete Multi-Tenant SaaS Guide: NestJS, Prisma & PostgreSQL with Database-per-Tenant Architecture

I’ve been thinking a lot about scalable SaaS architectures lately. What separates successful platforms from struggling ones? Often it’s how they handle multiple customers securely and efficiently. Today, I’ll share practical steps for building multi-tenant applications using NestJS, Prisma, and PostgreSQL. Follow along to implement robust tenant isolation while maintaining developer sanity.

When designing multi-tenant systems, we face fundamental choices. The shared database approach adds tenant IDs to every table. It’s simple but offers weak isolation. How confident would you feel storing sensitive data this way? Instead, we’ll use database-per-tenant architecture. Each customer gets their own PostgreSQL database. This provides strong security boundaries and customization options. The trade-off? More complex connection management. Let’s solve that.

We start by setting up our NestJS project:

nest new saas-platform
cd saas-platform
npm install @nestjs/config prisma @prisma/client
npx prisma init

Our folder structure organizes concerns clearly. Key directories include tenant-manager for connection logic and modules for business features. Configuration handles environment variables:

// src/config/config.ts
export default () => ({
  port: parseInt(process.env.PORT, 10) || 3000,
  masterDbUrl: process.env.MASTER_DB_URL,
  tenantDbTemplate: process.env.TENANT_DB_URL.replace('{tenant}', ''),
});

The master database stores tenant metadata. We define its schema with Prisma:

// prisma/schema.prisma
model Tenant {
  id          String   @id @default(cuid())
  name        String
  subdomain   String   @unique
  dbUrl       String
  status      TenantStatus @default(ACTIVE)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}

enum TenantStatus { ACTIVE SUSPENDED PENDING }

Tenant databases share a common schema template:

// prisma/tenant-template.prisma
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  tenantId  String
  createdAt DateTime @default(now())
}

The magic happens in our connection manager. We create a service that caches Prisma clients:

// src/tenant-manager/tenant.service.ts
@Injectable()
export class TenantService {
  private clients: { [key: string]: PrismaClient } = {};

  async getClient(tenantId: string): Promise<PrismaClient> {
    if (!this.clients[tenantId]) {
      const tenant = await this.masterDb.tenant.findUnique({
        where: { id: tenantId },
      });
      
      this.clients[tenantId] = new PrismaClient({
        datasources: { db: { url: tenant.dbUrl } },
      });
    }
    return this.clients[tenantId];
  }
}

Authentication must be tenant-aware. We modify Passport strategies to validate tenant context:

// src/auth/tenant.strategy.ts
@Injectable()
export class TenantStrategy extends PassportStrategy(Strategy) {
  constructor(private tenantService: TenantService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: { sub: string; tenantId: string }) {
    const prisma = await this.tenantService.getClient(payload.tenantId);
    return prisma.user.findUnique({ where: { id: payload.sub } });
  }
}

Request isolation is critical. We create middleware that resolves the tenant early:

// src/common/middleware/tenant.middleware.ts
export class TenantMiddleware implements NestMiddleware {
  constructor(private tenantService: TenantService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const tenantId = req.headers['x-tenant-id'] as string;
    if (!tenantId) throw new UnauthorizedException('Tenant not identified');
    
    req.prisma = await this.tenantService.getClient(tenantId);
    next();
  }
}

Database migrations need special handling. We script tenant provisioning:

// scripts/provision-tenant.ts
async function createTenant(name: string, subdomain: string) {
  const dbUrl = `${config.tenantDbTemplate}${subdomain}`;
  
  // Create physical database
  await masterDb.$executeRaw`CREATE DATABASE ${subdomain}`;
  
  // Migrate tenant schema
  const tenantPrisma = new PrismaClient({
    datasources: { db: { url: dbUrl } },
  });
  await tenantPrisma.$connect();
  await tenantPrisma.$executeRaw`CREATE SCHEMA IF NOT EXISTS ${subdomain}`;
  await tenantPrisma.$disconnect();

  // Store tenant record
  return masterDb.tenant.create({
    data: { name, subdomain, dbUrl },
  });
}

Performance requires smart pooling. We integrate Redis for caching tenant configurations:

// src/tenant-manager/tenant.service.ts
async getClient(tenantId: string): Promise<PrismaClient> {
  const cached = await redis.get(`tenant:${tenantId}`);
  if (cached) return new PrismaClient(JSON.parse(cached));

  // ...fetch from DB and cache
}

Security demands constant vigilance. We implement row-level security in PostgreSQL:

-- Enable RLS on tenant tables
ALTER TABLE users ENABLE ROW LEVEL SECURITY;

-- Policy ensuring data isolation
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant'));

Testing strategies must simulate multi-tenant environments. We use Jest to verify isolation:

// test/tenant-isolation.e2e-spec.ts
it('prevents data leakage between tenants', async () => {
  const tenantARes = await testServer(tenantA)
    .get('/users')
    .set('x-tenant-id', 'tenantA');
  
  const tenantBRes = await testServer(tenantB)
    .get('/users')
    .set('x-tenant-id', 'tenantB');

  expect(tenantARes.body).not.toEqual(tenantBRes.body);
});

Deployment considerations include connection limits. We use PgBouncer for pooling and set up monitoring with Prometheus. Alert rules watch for tenant-specific performance degradation.

Building multi-tenant systems is challenging but rewarding. Each piece must work in concert to achieve secure isolation without sacrificing developer experience. What questions do you have about scaling this further? Share your thoughts below - let’s keep learning together. If this helped you, please share it with others facing similar challenges.

Keywords: multi-tenant SaaS application, NestJS multi-tenancy tutorial, Prisma database-per-tenant architecture, PostgreSQL multi-tenant setup, tenant management system, dynamic database connections, multi-tenant authentication authorization, SaaS application security best practices, Prisma migrations multi-tenant, scalable multi-tenant NestJS



Similar Posts
Blog Image
Complete Guide to Svelte Supabase Integration: Build Full-Stack Apps with Real-Time Database Features

Learn how to integrate Svelte with Supabase to build powerful full-stack web apps with real-time features, authentication, and PostgreSQL database support.

Blog Image
Complete Guide to Next.js Prisma Integration: Build Type-Safe Full-Stack Apps in 2024

Learn how to integrate Next.js with Prisma ORM for type-safe, full-stack web applications. Build database-driven apps with seamless data flow and TypeScript support.

Blog Image
Server-Sent Events Guide: Build Real-Time Notifications with Express.js and Redis for Scalable Apps

Learn to build scalable real-time notifications with Server-Sent Events, Express.js & Redis. Complete guide with authentication, error handling & production tips.

Blog Image
Build High-Performance GraphQL API: Prisma ORM, Redis Caching & TypeScript Integration Guide

Build a high-performance GraphQL API with Prisma, Redis caching & TypeScript. Learn Apollo Server setup, DataLoader optimization & auth patterns.

Blog Image
Production-Ready Event-Driven Microservices with NestJS, RabbitMQ, and TypeScript

Learn to build production-ready event-driven microservices with NestJS, RabbitMQ & TypeScript. Includes error handling, tracing, and Docker deployment.