Designing Multi-Tenant SaaS Architecture with Next.js, NestJS and PostgreSQL
Building a multi-tenant SaaS system means making architectural decisions early that are extremely difficult to reverse later. This is a production walkthrough based on building Ackura ERP — a live multi-tenant platform serving general business needs across Accounting, HR, Inventory, and POS.
The Multi-Tenancy Decision You Cannot Take Back
Building a multi-tenant SaaS system means making architectural decisions early that are extremely difficult to reverse later. The data isolation strategy you choose at the start will shape your entire database schema, your ORM configuration, your backend middleware, and your cost structure for years.
This is not a theoretical walkthrough. It is based directly on building Ackura ERP — a production multi-tenant SaaS platform serving general business needs across Accounting, HR, Inventory, and POS — using Next.js 15, NestJS 11, and PostgreSQL with a per-tenant schema isolation model.
1. The Three Data Isolation Strategies
Every multi-tenant architecture maps to one of three data isolation models, each representing a tradeoff between isolation, cost, and operational complexity.
Database Per Tenant (Highest Isolation)
Each tenant gets a completely separate database instance. Maximum security, maximum isolation, zero risk of cross-tenant data leakage even from bugs. The tradeoff: you need to manage N databases, run migrations N times, and your infrastructure cost scales directly with tenant count. This model makes economic sense at a high price point with a small number of enterprise customers.
Schema Per Tenant (Balanced — Our Choice for Ackura)
A single PostgreSQL instance with a separate schema for each tenant. Each tenant's tables exist in their own namespace — tenant_abc.invoices, tenant_xyz.invoices. Strong isolation, single database to manage, migrations run once per schema but via scripted tooling. This is the sweet spot for most B2B SaaS applications with dozens to hundreds of tenants.
Shared Schema with tenant_id Column (Most Cost-Effective)
All tenants share the same tables. Every table has a tenant_id column and every query must include a WHERE tenant_id = ? clause. Lowest operational overhead, but the isolation is entirely application-enforced — a missing WHERE clause leaks data across tenants. Row Level Security (RLS) in PostgreSQL can enforce this at the database level, which significantly reduces the risk.
2. Implementing Schema-Per-Tenant with TypeORM and NestJS
The core challenge of schema-per-tenant is that your application needs to connect to the right schema for every incoming request. NestJS's REQUEST-scoped providers and TypeORM's dynamic connection management make this achievable.
The Tenant Context Provider
// tenant-context.provider.ts
@Injectable({ scope: Scope.REQUEST })
export class TenantContext {
private tenantId: string;
constructor(@Inject(REQUEST) private readonly request: Request) {
// Extract tenant from subdomain header set by our Next.js middleware
const host = request.headers['x-tenant-id'] as string;
if (!host) throw new UnauthorizedException('Tenant context not found');
this.tenantId = host;
}
getId(): string {
return this.tenantId;
}
getSchemaName(): string {
return `tenant_${this.tenantId.replace(/-/g, '_')}`;
}
}
Dynamic Schema Selection with TypeORM
// tenant-database.service.ts
@Injectable({ scope: Scope.REQUEST })
export class TenantDatabaseService {
constructor(
private readonly tenantContext: TenantContext,
private readonly dataSource: DataSource,
) {}
// Returns a QueryRunner scoped to the current tenant's schema
async getTenantRunner(): Promise<QueryRunner> {
const schema = this.tenantContext.getSchemaName();
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.query(`SET search_path TO ${schema}, public`);
return runner;
}
// Execute a callback within the tenant schema context
async withTenantSchema<T>(
callback: (runner: QueryRunner) => Promise<T>,
): Promise<T> {
const runner = await this.getTenantRunner();
try {
return await callback(runner);
} finally {
await runner.release();
}
}
}
3. Subdomain-Based Tenant Routing in Next.js
Each tenant in Ackura accesses the application through their own subdomain: acme.ackura.net, globex.ackura.net. Next.js middleware extracts the subdomain and attaches it as a request header, making it available to the backend API and to Server Components without any client-side code.
// middleware.ts — Next.js middleware
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
const hostname = request.headers.get('host') ?? '';
const subdomain = hostname.split('.')[0];
// Skip for the root domain and known non-tenant subdomains
const nonTenantSubdomains = ['www', 'app', 'api', 'demo'];
if (nonTenantSubdomains.includes(subdomain)) {
return NextResponse.next();
}
// Clone the request headers and inject the tenant ID
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-tenant-id', subdomain);
return NextResponse.next({
request: { headers: requestHeaders },
});
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
Reading Tenant Context in Server Components
// app/dashboard/page.tsx — Server Component
import { headers } from 'next/headers';
export default async function DashboardPage() {
const headersList = await headers();
const tenantId = headersList.get('x-tenant-id');
// Pass tenant ID to API calls — each fetch is scoped to the correct tenant
const data = await fetch(`${process.env.API_URL}/dashboard`, {
headers: { 'x-tenant-id': tenantId ?? '' },
cache: 'no-store', // per-tenant data must never be shared in the Data Cache
}).then(r => r.json());
return <DashboardContent data={data} />;
}
4. Tenant Provisioning: Creating a New Tenant
When a new tenant signs up, the provisioning process must create their PostgreSQL schema and run all migrations against it. This should be an atomic operation — if any step fails, the entire schema creation is rolled back.
// tenant-provisioning.service.ts
@Injectable()
export class TenantProvisioningService {
constructor(private readonly dataSource: DataSource) {}
async provisionTenant(tenantId: string): Promise<void> {
const schemaName = `tenant_${tenantId.replace(/-/g, '_')}`;
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. Create the schema
await queryRunner.query(
`CREATE SCHEMA IF NOT EXISTS ${schemaName}`,
);
// 2. Set search_path to new schema
await queryRunner.query(
`SET search_path TO ${schemaName}`,
);
// 3. Run migrations in the tenant schema context
await this.runMigrationsInSchema(queryRunner, schemaName);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException(
`Failed to provision tenant ${tenantId}: ${error.message}`,
);
} finally {
await queryRunner.release();
}
}
}
5. RBAC: Per-Tenant Roles and Permissions
Each tenant has their own set of users, roles, and permissions. The RBAC system must be tenant-scoped — a user's permissions in tenant_abc are completely independent from any permissions they might have in tenant_xyz.
// permission.decorator.ts
export const PERMISSIONS_KEY = 'permissions';
export const RequirePermissions = (...permissions: string[]) =>
SetMetadata(PERMISSIONS_KEY, permissions);
// permissions.guard.ts
@Injectable()
export class PermissionsGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly tenantContext: TenantContext,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const required = this.reflector.getAllAndOverride<string[]>(
PERMISSIONS_KEY,
[context.getHandler(), context.getClass()],
);
if (!required?.length) return true;
const { user } = context.switchToHttp().getRequest();
// Permissions are already validated against the tenant's schema
// by the JWT strategy — user.permissions is tenant-scoped
return required.every(perm => user.permissions?.includes(perm));
}
}
// Usage on a controller
@Post()
@UseGuards(JwtAuthGuard, PermissionsGuard)
@RequirePermissions('invoices:create')
createInvoice(@Body() dto: CreateInvoiceDto) {
return this.invoicesService.create(dto);
}
Common Mistakes
- Caching per-tenant API responses in a shared cache. Never use Next.js Data Cache or any shared server-side cache for tenant-specific data. Use
cache: 'no-store'and handle caching client-side with tenant-scoped keys. - Forgetting to set search_path before every tenant operation. PostgreSQL connections in a pool may retain the previous session's search_path. Always set it explicitly at the start of a tenant-scoped operation.
- Running migrations without schema scoping. Running
typeorm migration:runwithout specifying the schema will run migrations against the public schema. Build migration tooling that iterates over all tenant schemas. - Not validating tenant existence on every request. A valid JWT does not mean a valid tenant. Verify that the tenant ID in the request corresponds to an active, provisioned tenant on every authenticated request.
Production Best Practices
- Use PostgreSQL connection pooling (PgBouncer or built-in pool) carefully with schema-per-tenant. Connections are schema-agnostic at the pool level — your application must re-set
search_pathon every acquired connection. - Automate tenant provisioning and migration tooling from day one. Manual schema creation does not scale past a handful of tenants.
- Store tenant metadata (subdomain, status, plan, created date) in a shared public schema — not in tenant-specific schemas. This makes super-admin operations and billing queries simple.
- Implement a super-admin interface with a separate authentication path that can operate across all tenant schemas. This is essential for support operations and data migrations.
- Monitor schema count and connection pool usage. PostgreSQL can handle thousands of schemas, but connection pool configuration needs to account for the potential multiplied connection demand from concurrent request-scoped providers.
Conclusion
Multi-tenant SaaS architecture is about making the right isolation tradeoff for your product, then building the infrastructure to enforce that isolation consistently at every layer — database, backend, and frontend. Schema-per-tenant in PostgreSQL with NestJS REQUEST-scoped providers and Next.js subdomain middleware gives you strong isolation with manageable operational overhead.
The architecture described here powers the Ackura ERP in production. It handles multi-tenant data isolation, per-tenant RBAC, and a subdomain-based routing model that makes the tenant boundary invisible to end users. The key is making these decisions deliberately and early — changing your data isolation model after launch is one of the most painful migrations a SaaS product can undergo. Explore the projects section for more context on these production systems.
Frequently Asked Questions
Which multi-tenant isolation model should I choose?
Shared schema with tenant_id is the right default for early-stage products — simplest to implement and cheapest to operate. Move to schema-per-tenant when you have compliance requirements (data residency, audit isolation) or when query complexity around tenant_id filtering becomes a performance bottleneck. Database-per-tenant is only justified for enterprise contracts with strict contractual isolation requirements.
How do you handle database migrations in a schema-per-tenant model?
Build a migration runner that queries the list of active tenant schemas from the public schema's tenants table, then runs TypeORM migrations against each schema sequentially or in controlled parallel batches. This tooling needs to be part of your deployment pipeline. Migrations must be backward-compatible — run migrations first, deploy application code second.
Is there a performance cost to REQUEST-scoped providers in NestJS?
Yes. NestJS must instantiate a new provider instance and rebuild the DI subtree for every HTTP request. For providers used on every request (like TenantContext), this overhead is acceptable. Avoid making services that perform expensive initialization work REQUEST-scoped — keep heavy singletons as SINGLETON scope and inject only the lightweight request-context provider at the REQUEST scope.
How do you secure the x-tenant-id header from being spoofed?
The x-tenant-id header should only be trusted when set by your own infrastructure — Next.js middleware or an API gateway. In production, configure your NestJS application to only accept traffic from trusted internal sources (your Next.js deployment or API gateway), not directly from the public internet. The JWT token should also contain and validate the tenant ID claim so that even if the header is manipulated, the token validation will reject the mismatch.