Skip to main content

Command Palette

Search for a command to run...

How to Build a Multi-Tenant Backend with NestJS

A practical guide to building multi-tenant backends with NestJS, PostgreSQL, and Prisma. Real patterns, schema design, and lessons from production.

Updated
8 min read

What is Multi-Tenancy?

Multi-tenancy means one application serves multiple independent clients (tenants) — each with their own isolated data.

Think of it like an apartment building:

One building (your app)
├── Apartment 101 (Tenant A — their data)
├── Apartment 102 (Tenant B — their data)
└── Apartment 103 (Tenant C — their data)

Each tenant shares the same infrastructure but never sees each other's data.

Common examples:

  • SaaS platforms (Slack, Notion, Jira)

  • Agency dashboards managing multiple clients

  • Notification systems serving multiple businesses


Three Approaches to Multi-Tenancy

Before writing code, you need to pick the right isolation strategy:

Approach How It Works Best For
Separate databases One DB per tenant High security, enterprise clients
Separate schemas One schema per tenant in same DB Medium isolation, mid-size SaaS
Shared schema All tenants in same tables with tenantId Startups, cost-effective scaling

I use shared schema with tenantId for most freelance projects. It's the most practical approach — easy to implement, cost-effective, and scales well up to millions of rows.

This is what we'll build today.


Database Schema Design

The foundation of a multi-tenant system is getting the schema right.

// prisma/schema.prisma

model Tenant {
  id        String   @id @default(cuid())
  name      String
  slug      String   @unique  // used for subdomain: tenant.yoursaas.com
  plan      Plan     @default(FREE)
  isActive  Boolean  @default(true)
  createdAt DateTime @default(now())

  users        User[]
  products     Product[]
  orders       Order[]
  subscription Subscription?

  @@index([slug])
}

model User {
  id       String   @id @default(cuid())
  email    String
  name     String
  password String
  role     UserRole @default(MEMBER)

  tenantId String
  tenant   Tenant @relation(fields: [tenantId], references: [id])

  createdAt DateTime @default(now())

  // Email unique PER tenant, not globally
  @@unique([email, tenantId])
  @@index([tenantId])
}

model Product {
  id          String  @id @default(cuid())
  name        String
  description String?
  price       Float
  stock       Int     @default(0)

  tenantId String
  tenant   Tenant @relation(fields: [tenantId], references: [id])

  createdAt DateTime @default(now())

  @@index([tenantId])
  @@index([tenantId, createdAt(sort: Desc)])
}

model Order {
  id     String      @id @default(cuid())
  status OrderStatus @default(PENDING)
  total  Float

  tenantId String
  tenant   Tenant @relation(fields: [tenantId], references: [id])

  createdAt DateTime @default(now())

  @@index([tenantId, status])
  @@index([tenantId, createdAt(sort: Desc)])
}

enum Plan {
  FREE
  PRO
  ENTERPRISE
}

enum UserRole {
  OWNER
  ADMIN
  MEMBER
}

enum OrderStatus {
  PENDING
  CONFIRMED
  SHIPPED
  DELIVERED
  CANCELLED
}

Key design decisions:

  • Every table has tenantId — this is the core isolation mechanism

  • @@index([tenantId]) on every table — critical for query performance

  • Email is unique per tenant, not globally — user@gmail.com can exist in multiple tenants

  • Composite indexes on common query patterns — [tenantId, createdAt], [tenantId, status]


Tenant Resolution — How Does the App Know Which Tenant?

Before any request is processed, the app needs to know which tenant is making it.

Three common strategies:

1. Subdomain  → tenant-a.yoursaas.com
2. Header     → X-Tenant-ID: tenant_123
3. JWT claim  → { sub: userId, tenantId: tenant_123 }

I use JWT claim for API-first SaaS and subdomain for white-label products. Here's the JWT approach:

// src/common/decorators/tenant.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentTenant = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    return request.tenant;
  },
);
// src/common/guards/tenant.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
  NotFoundException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class TenantGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
    private prisma: PrismaService,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.split(' ')[1];

    if (!token) throw new UnauthorizedException('No token provided');

    const payload = this.jwtService.verify(token);

    // Fetch tenant and attach to request
    const tenant = await this.prisma.tenant.findUnique({
      where: { id: payload.tenantId },
    });

    if (!tenant || !tenant.isActive) {
      throw new NotFoundException('Tenant not found or inactive');
    }

    request.tenant = tenant;
    request.user = payload;

    return true;
  }
}

The Most Important Rule — Always Filter by tenantId

This is where most developers make mistakes. Every single database query must filter by tenantId.

// src/modules/products/products.service.ts
@Injectable()
export class ProductsService {
  constructor(private prisma: PrismaService) {}

  // ❌ WRONG — returns products from ALL tenants
  async findAll() {
    return this.prisma.product.findMany();
  }

  // ✅ CORRECT — scoped to tenant
  async findAll(tenantId: string) {
    return this.prisma.product.findMany({
      where: { tenantId },
      orderBy: { createdAt: 'desc' },
    });
  }

  // ❌ WRONG — could return another tenant's product
  async findOne(id: string) {
    return this.prisma.product.findUnique({
      where: { id },
    });
  }

  // ✅ CORRECT — validates both id AND tenantId
  async findOne(id: string, tenantId: string) {
    const product = await this.prisma.product.findFirst({
      where: { id, tenantId },
    });

    if (!product) throw new NotFoundException('Product not found');
    return product;
  }
}

Missing a tenantId filter on a single query is a data breach. Tenant A could see Tenant B's data. Never skip it.


Tenant-Scoped Controller

// src/modules/products/products.controller.ts
@Controller('products')
@UseGuards(TenantGuard)
export class ProductsController {
  constructor(private productsService: ProductsService) {}

  @Get()
  findAll(@CurrentTenant() tenant: Tenant) {
    return this.productsService.findAll(tenant.id);
  }

  @Get(':id')
  findOne(@Param('id') id: string, @CurrentTenant() tenant: Tenant) {
    return this.productsService.findOne(id, tenant.id);
  }

  @Post()
  create(@Body() dto: CreateProductDto, @CurrentTenant() tenant: Tenant) {
    return this.productsService.create(dto, tenant.id);
  }

  @Patch(':id')
  update(
    @Param('id') id: string,
    @Body() dto: UpdateProductDto,
    @CurrentTenant() tenant: Tenant,
  ) {
    return this.productsService.update(id, dto, tenant.id);
  }

  @Delete(':id')
  remove(@Param('id') id: string, @CurrentTenant() tenant: Tenant) {
    return this.productsService.remove(id, tenant.id);
  }
}

Tenant Registration Flow

// src/modules/tenant/tenant.service.ts
@Injectable()
export class TenantService {
  constructor(private prisma: PrismaService) {}

  async register(dto: RegisterTenantDto) {
    // Check slug availability
    const exists = await this.prisma.tenant.findUnique({
      where: { slug: dto.slug },
    });

    if (exists) throw new ConflictException('Slug already taken');

    // Create tenant + owner in one transaction
    const result = await this.prisma.$transaction(async (tx) => {
      const tenant = await tx.tenant.create({
        data: {
          name: dto.businessName,
          slug: dto.slug,
        },
      });

      const hashedPassword = await bcrypt.hash(dto.password, 12);

      const owner = await tx.user.create({
        data: {
          email: dto.email,
          name: dto.name,
          password: hashedPassword,
          role: 'OWNER',
          tenantId: tenant.id,
        },
      });

      return { tenant, owner };
    });

    const token = this.generateToken(result.owner.id, result.tenant.id);

    return {
      tenant: result.tenant,
      user: { id: result.owner.id, email: result.owner.email },
      token,
    };
  }

  private generateToken(userId: string, tenantId: string) {
    return this.jwtService.sign({ sub: userId, tenantId });
  }
}

Plan-Based Feature Gating

Different tenants on different plans get different features:

// src/common/guards/plan.guard.ts
import { SetMetadata } from '@nestjs/common';

export const RequiredPlan = (...plans: Plan[]) =>
  SetMetadata('plans', plans);

@Injectable()
export class PlanGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const requiredPlans = this.reflector.get<Plan[]>('plans', context.getHandler());
    if (!requiredPlans) return true;

    const request = context.switchToHttp().getRequest();
    const tenant = request.tenant;

    if (!requiredPlans.includes(tenant.plan)) {
      throw new ForbiddenException(
        `This feature requires ${requiredPlans.join(' or ')} plan`
      );
    }

    return true;
  }
}

// Usage on any endpoint
@Post('bulk-export')
@RequiredPlan(Plan.PRO, Plan.ENTERPRISE)
@UseGuards(TenantGuard, PlanGuard)
async bulkExport(@CurrentTenant() tenant: Tenant) {
  return this.exportService.bulkExport(tenant.id);
}

Performance at Scale

As your tenant count grows, these optimizations become critical:

1. Composite Indexes on Every Table

-- Every tenant-scoped query benefits from this
CREATE INDEX idx_products_tenant_created 
ON products(tenant_id, created_at DESC);

CREATE INDEX idx_orders_tenant_status 
ON orders(tenant_id, status);

2. Connection Pooling with PgBouncer

Without PgBouncer:
100 tenants × 10 concurrent users = 1,000 DB connections

With PgBouncer:
1,000 app connections → 50 actual DB connections

3. Redis Caching for Tenant Lookups

async getTenant(tenantId: string) {
  const cacheKey = `tenant:${tenantId}`;

  const cached = await this.redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const tenant = await this.prisma.tenant.findUnique({
    where: { id: tenantId },
  });

  // Cache for 5 minutes — tenant data rarely changes
  await this.redis.setex(cacheKey, 300, JSON.stringify(tenant));

  return tenant;
}

Common Mistakes to Avoid

❌ Missing tenantId filter on any query → data breach
❌ Storing tenantId only in JWT without validating in DB → security risk
❌ No indexes on tenantId columns → slow queries at scale
❌ Single database connection pool for all tenants → bottleneck
❌ Hardcoding plan limits in code → painful to update
✅ Always filter by tenantId
✅ Validate tenant exists and is active on every request
✅ Index every tenantId column
✅ Store plan limits in database — not code

Real World Application

I used this exact multi-tenant pattern to build the backend for The Design Ethos — a Flutter-based platform where multiple organizations manage their own users, content, and settings independently.

The same architecture also powers the notification engine I described in my post on building a dynamic notification engine with BullMQ and Redis — where each tenant configures their own templates and providers.


Summary

Multi-tenancy with shared schema comes down to three things:

1. Every table has tenantId
2. Every query filters by tenantId  
3. Every request resolves the tenant first

Get those three right and the rest is standard NestJS development.


Need a multi-tenant backend built for your SaaS? Let's discuss your project — I've built this architecture in production and can deliver a production-ready system for your business.


Have questions about the implementation? Drop a comment below.