js

Build Multi-Tenant SaaS with NestJS, Prisma, and PostgreSQL Row-Level Security: Complete Guide

Learn to build secure multi-tenant SaaS applications with NestJS, Prisma, and PostgreSQL RLS. Step-by-step guide with tenant isolation, auth, and deployment tips.

Build Multi-Tenant SaaS with NestJS, Prisma, and PostgreSQL Row-Level Security: Complete Guide

I’ve been thinking a lot about building software that scales gracefully while keeping customer data completely isolated. The challenge of creating a single application that securely serves multiple organizations is something I’ve faced repeatedly in my career. Today, I want to share a practical approach to building multi-tenant SaaS applications that balances security, performance, and maintainability.

Why does this matter? Because when you’re building for multiple customers, data isolation isn’t just a feature—it’s a fundamental requirement. I’ve seen teams struggle with complex application-level filtering when database-level solutions exist that are both simpler and more secure.

Let me show you how PostgreSQL’s Row-Level Security can transform your multi-tenant architecture.

The foundation starts with our database schema. We need to design tables that naturally support tenant isolation while maintaining flexibility for future features.

model Tenant {
  id        String   @id @default(cuid())
  name      String   @unique
  slug      String   @unique
  createdAt DateTime @default(now())
  
  users     User[]
  projects  Project[]
}

model User {
  id        String   @id @default(cuid())
  email     String   
  tenantId  String
  tenant    Tenant   @relation(fields: [tenantId], references: [id])
  
  @@unique([email, tenantId])
}

Notice how every user belongs to a specific tenant? This relationship becomes the backbone of our security model. But how do we ensure users can only access their own tenant’s data?

PostgreSQL’s Row-Level Security policies handle this elegantly. Instead of relying on application code to filter results, we push security to the database level.

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

-- Create policy for tenant isolation
CREATE POLICY tenant_isolation_policy ON users
  USING (tenant_id = current_setting('app.current_tenant')::uuid);

The magic happens with current_setting('app.current_tenant'). This lets us set a session-specific value that PostgreSQL uses to filter every query. But how do we ensure this value is always set correctly?

In our NestJS application, we use middleware to automatically set the tenant context for each request.

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) {
    const tenantId = this.extractTenantId(req);
    
    // Set tenant context for database queries
    req['tenantId'] = tenantId;
    next();
  }
  
  private extractTenantId(req: Request): string {
    // Extract from subdomain, header, or JWT token
    return req.headers['x-tenant-id'] as string;
  }
}

This middleware runs before any route handler, ensuring our tenant context is always available. But what about database connections? We need to configure Prisma to use our tenant context.

@Injectable()
export class PrismaService extends PrismaClient {
  constructor(private config: ConfigService) {
    super();
  }

  async enableSharding(tenantId: string) {
    await this.$executeRaw`SET app.current_tenant = ${tenantId}`;
  }
}

Now, every database operation automatically respects tenant boundaries. When a user from Tenant A queries their projects, they only see Tenant A’s projects—even if we forget to add a WHERE clause.

But what happens during tenant onboarding? We need a clean way to create new tenant contexts without manual database intervention.

@Controller('tenants')
export class TenantsController {
  constructor(private tenantsService: TenantsService) {}

  @Post()
  async createTenant(@Body() createTenantDto: CreateTenantDto) {
    return this.tenantsService.create(createTenantDto);
  }
}

@Injectable()
export class TenantsService {
  async create(createTenantDto: CreateTenantDto) {
    return this.prisma.$transaction(async (tx) => {
      const tenant = await tx.tenant.create({
        data: {
          name: createTenantDto.name,
          slug: createTenantDto.slug,
        },
      });

      // Create default admin user
      await tx.user.create({
        data: {
          email: createTenantDto.adminEmail,
          tenantId: tenant.id,
          role: 'ADMIN',
        },
      });

      return tenant;
    });
  }
}

This transactional approach ensures we never end up with incomplete tenant setups. But have you considered what happens when tenants need custom fields or configurations?

The beauty of this architecture is its flexibility. We can extend our base models with tenant-specific settings using JSON fields.

model Tenant {
  id       String @id @default(cuid())
  name     String @unique
  settings Json   @default("{}")
}

model Project {
  id          String @id @default(cuid())
  name        String
  tenantId    String
  tenant      Tenant @relation(fields: [tenantId], references: [id])
  customFields Json   @default("{}")
}

JSON fields let tenants customize their experience without requiring schema changes. But how do we maintain performance with this approach?

Indexing strategic columns and using partial indexes can help significantly.

-- Index tenant ID for faster lookups
CREATE INDEX CONCURRENTLY idx_users_tenant_id 
ON users(tenant_id) WHERE tenant_id IS NOT NULL;

-- Partial index for active tenants
CREATE INDEX CONCURRENTLY idx_tenants_active 
ON tenants(id) WHERE status = 'ACTIVE';

Testing becomes crucial in multi-tenant environments. We need to verify that data never leaks between tenants.

describe('Multi-tenant Security', () => {
  it('should not leak data between tenants', async () => {
    const [tenantA, tenantB] = await createTestTenants();
    
    // Create projects in both tenants
    await createProject(tenantA.id, { name: 'Tenant A Project' });
    await createProject(tenantB.id, { name: 'Tenant B Project' });

    // Query as Tenant A
    const tenantAProjects = await getProjects(tenantA.id);
    
    expect(tenantAProjects).toHaveLength(1);
    expect(tenantAProjects[0].name).toBe('Tenant A Project');
  });
});

This test validates that our RLS policies work correctly. But what about edge cases, like when a user belongs to multiple tenants?

We handle this by ensuring each request context is explicitly set to a single tenant. Users authenticate per-tenant, maintaining clear boundaries.

Performance monitoring becomes essential at scale. We need to track query performance across different tenant sizes.

@Injectable()
export class QueryLogger implements Prisma.Middleware {
  async use(params: Prisma.MiddlewareParams, next: (params: Prisma.MiddlewareParams) => Promise<any>) {
    const start = Date.now();
    const result = await next(params);
    const duration = Date.now() - start;
    
    // Log slow queries per tenant
    if (duration > 1000) {
      this.logger.warn(`Slow query for tenant ${params.args?.where?.tenantId}`, {
        model: params.model,
        action: params.action,
        duration,
      });
    }
    
    return result;
  }
}

This middleware helps identify performance issues specific to certain tenants or query patterns.

Deployment considerations change with multi-tenancy. We need strategies for zero-downtime migrations and tenant-specific backups.

-- Tenant-aware backup strategy
pg_dump --table='projects' --where="tenant_id='tenant-123'" 

But what about database connection pooling? We need to ensure connection reuse while maintaining tenant isolation.

The solution involves setting the tenant context at the connection level, either through connection pooling with context or application-level connection management.

This approach has served me well in production environments, handling thousands of tenants with varying data sizes. The key insight is trusting the database with security while keeping business logic in the application.

What challenges have you faced with multi-tenant architectures? I’d love to hear about your experiences and solutions.

If this approach resonates with you, consider sharing it with others who might benefit. Your thoughts and questions in the comments help everyone learn—what aspects of multi-tenancy would you like me to cover next?

Keywords: multi-tenant SaaS application, NestJS multi-tenancy tutorial, PostgreSQL row-level security, Prisma multi-tenant schema, SaaS architecture with NestJS, tenant isolation database design, NestJS Prisma PostgreSQL tutorial, building scalable SaaS applications, multi-tenant authentication system, PostgreSQL RLS implementation



Similar Posts
Blog Image
Complete Guide to Event-Driven Microservices: NestJS, RabbitMQ, and TypeScript Tutorial

Learn to build scalable event-driven microservices with NestJS, RabbitMQ & TypeScript. Master SAGA patterns, error handling & deployment strategies.

Blog Image
Build High-Performance File Upload System with Fastify Multer and AWS S3 Integration

Learn to build a high-performance file upload system with Fastify, Multer & AWS S3. Includes streaming, validation, progress tracking & production deployment tips.

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

Build type-safe full-stack apps with Next.js and Prisma ORM. Learn seamless integration, TypeScript support, and powerful database operations. Start building today!

Blog Image
Vue.js Socket.io Integration: Build Real-Time Web Applications with Instant Data Updates

Learn to integrate Vue.js with Socket.io for building powerful real-time web applications. Master instant updates, chat features & live dashboards today.

Blog Image
Complete Guide to Integrating Svelte with Supabase for Modern Full-Stack Web Applications

Learn how to integrate Svelte with Supabase for powerful full-stack web applications. Build real-time apps with authentication, databases & minimal setup.

Blog Image
Complete Guide to Integrating Next.js with Prisma ORM for Type-Safe Database Applications

Learn to integrate Next.js with Prisma ORM for type-safe, database-driven web apps. Build scalable full-stack applications with seamless developer experience.