Skip to content
NestJS projects 5 min read

Project: Multi-Tenant SaaS

Multi-tenancy lets a single deployment of your application serve many customers (tenants) while keeping their data logically — and sometimes physically — separate. Getting this right is the difference between a SaaS that scales cleanly and one that leaks data across accounts. In this project you’ll resolve the tenant on every request, pick an isolation strategy, wire up request-scoped providers, and make authentication tenant-aware.

Choosing an isolation strategy

There are three classic approaches to tenant data isolation. Each trades operational complexity for stronger separation.

StrategySeparationCost / complexityBest for
Row-level (shared schema)A tenantId column on every rowLowest — one DB, one schemaMany small tenants, fast onboarding
Schema-per-tenantSeparate Postgres schema per tenantMedium — migrations per schemaMid-size tenants needing soft isolation
Database-per-tenantSeparate database/connection per tenantHighest — connection pooling, opsEnterprise tenants, compliance, noisy-neighbor isolation

Row-level isolation is the most common starting point because it scales to thousands of tenants on shared infrastructure. The risk is that a single missing WHERE tenantId = ? clause leaks data — so you centralize that filtering rather than scattering it across queries.

Never rely on the client to send its own tenantId in the request body. Always derive the tenant from a trusted source: a verified JWT claim, a subdomain, or a custom header validated against the authenticated user.

Resolving the tenant per request

The tenant is resolved from the incoming request — typically a subdomain (acme.app.com), a header (X-Tenant-ID), or a JWT claim. A small middleware extracts it and attaches it to the request object before guards and providers run.

// tenant/tenant.middleware.ts
import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

export interface TenantRequest extends Request {
  tenantId?: string;
}

@Injectable()
export class TenantMiddleware implements NestMiddleware {
  use(req: TenantRequest, _res: Response, next: NextFunction) {
    const host = req.headers.host ?? '';
    const subdomain = host.split('.')[0];
    const headerTenant = req.headers['x-tenant-id'] as string | undefined;

    const tenantId = headerTenant ?? (subdomain && subdomain !== 'www' ? subdomain : undefined);
    if (!tenantId) {
      throw new BadRequestException('Unable to resolve tenant');
    }

    req.tenantId = tenantId;
    next();
  }
}

Register the middleware globally so it runs for every route.

// app.module.ts
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { TenantMiddleware } from './tenant/tenant.middleware';
import { TenantModule } from './tenant/tenant.module';

@Module({
  imports: [TenantModule],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TenantMiddleware).forRoutes('*');
  }
}

A request-scoped tenant context

To make the resolved tenant available anywhere in the DI graph, expose it through a request-scoped provider. NestJS creates a fresh instance per request, so it can safely capture per-request state.

// tenant/tenant-context.service.ts
import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { TenantRequest } from './tenant.middleware';

@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
  constructor(@Inject(REQUEST) private readonly req: TenantRequest) {}

  get tenantId(): string {
    if (!this.req.tenantId) {
      throw new Error('Tenant not resolved for this request');
    }
    return this.req.tenantId;
  }
}

Request-scoped providers bubble up: any provider that injects TenantContext (and its consumers) also becomes request-scoped, which adds per-request instantiation overhead. Keep the scoped surface small and resolve the tenant once.

Enforcing row-level isolation

Centralize the tenant filter so application code never forgets it. With Prisma you can wrap queries; with TypeORM you can apply the tenant in the repository layer. Here’s a tenant-aware service built on Prisma.

// projects/projects.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { TenantContext } from '../tenant/tenant-context.service';

@Injectable()
export class ProjectsService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly tenant: TenantContext,
  ) {}

  findAll() {
    return this.prisma.project.findMany({
      where: { tenantId: this.tenant.tenantId },
    });
  }

  create(name: string) {
    return this.prisma.project.create({
      data: { name, tenantId: this.tenant.tenantId },
    });
  }
}

Because ProjectsService injects the request-scoped TenantContext, it automatically scopes every query to the current tenant — there is no path for a caller to query another tenant’s rows.

Tenant-aware authentication

Authentication must verify that the authenticated user actually belongs to the resolved tenant. A guard cross-checks the JWT’s tenant claim against the request tenant.

// auth/tenant.guard.ts
import { CanActivate, ExecutionContext, Injectable, ForbiddenException } from '@nestjs/common';

@Injectable()
export class TenantGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    const userTenant = req.user?.tenantId;
    if (!userTenant || userTenant !== req.tenantId) {
      throw new ForbiddenException('User does not belong to this tenant');
    }
    return true;
  }
}

Apply it after your JWT auth guard so req.user is populated.

// projects/projects.controller.ts
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { TenantGuard } from '../auth/tenant.guard';
import { ProjectsService } from './projects.service';

@UseGuards(AuthGuard('jwt'), TenantGuard)
@Controller('projects')
export class ProjectsController {
  constructor(private readonly projects: ProjectsService) {}

  @Get()
  findAll() {
    return this.projects.findAll();
  }

  @Post()
  create(@Body('name') name: string) {
    return this.projects.create(name);
  }
}

A request from tenant acme carrying a token issued for tenant globex is rejected:

Output:

GET /projects  Host: acme.app.com  Authorization: Bearer <globex-token>

HTTP/1.1 403 Forbidden
{
  "statusCode": 403,
  "message": "User does not belong to this tenant",
  "error": "Forbidden"
}

Database-per-tenant connections

For stronger isolation, resolve a connection per tenant instead of filtering rows. A factory provider can look up the tenant’s database URL and return a dedicated client, cached per tenant.

// tenant/tenant-connection.provider.ts
import { Provider, Scope } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { TenantContext } from './tenant-context.service';

const clients = new Map<string, PrismaClient>();

export const TenantConnectionProvider: Provider = {
  provide: 'TENANT_DB',
  scope: Scope.REQUEST,
  inject: [TenantContext],
  useFactory: (tenant: TenantContext) => {
    const id = tenant.tenantId;
    if (!clients.has(id)) {
      clients.set(id, new PrismaClient({ datasources: { db: { url: `postgres://db/${id}` } } }));
    }
    return clients.get(id)!;
  },
};

Caching clients avoids exhausting the connection pool by re-creating a PrismaClient on every request.

Best Practices

  • Derive tenantId only from trusted, server-verified sources (JWT claims, validated subdomains) — never from a request body.
  • Centralize the tenant filter in one layer (a scoped service, repository, or Prisma extension) so no query can forget it.
  • Keep request-scoped providers minimal; resolve the tenant once and pass the id rather than scoping your whole graph.
  • Always cross-check the authenticated user’s tenant against the resolved request tenant in a guard.
  • Add a composite index on (tenantId, ...) for row-level tenancy so per-tenant queries stay fast.
  • For database-per-tenant, cache and reuse connections, and bound the pool to avoid noisy-neighbor exhaustion.
  • Write integration tests that assert tenant A can never read tenant B’s data across every endpoint.
Last updated June 14, 2026
Was this helpful?