Back to Journal
SaaS Engineering

Complete Guide to Multi-Tenant Architecture with Typescript

A comprehensive guide to implementing Multi-Tenant Architecture using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 18 min read

Multi-tenant architecture is the backbone of SaaS platforms, and TypeScript's type system gives you powerful tools to enforce tenant isolation at compile time rather than catching boundary violations in production. This guide covers implementing multi-tenancy in TypeScript across Node.js backends, from tenant context propagation to database isolation patterns.

Tenancy Models Overview

The three standard models apply regardless of language:

  • Database-per-tenant: Complete physical isolation. Best for regulated industries or enterprise customers with strict compliance requirements.
  • Schema-per-tenant: Logical isolation within a shared database. Good balance of isolation and operational cost.
  • Shared-schema with row-level isolation: All tenants in shared tables, filtered by tenant_id. Most cost-efficient, requires careful implementation.

TypeScript's type system lets you encode the tenancy model choice into your type hierarchy, preventing accidental cross-tenant data access at the compiler level.

Type-Safe Tenant Context

Start with a strongly-typed tenant context using AsyncLocalStorage, the Node.js equivalent of thread-local storage that works correctly with async/await:

typescript
1import { AsyncLocalStorage } from 'node:async_hooks';
2 
3interface TenantInfo {
4 readonly tenantId: string;
5 readonly plan: 'free' | 'pro' | 'enterprise';
6 readonly dbSchema: string;
7 readonly isIsolated: boolean;
8}
9 
10const tenantStorage = new AsyncLocalStorage<TenantInfo>();
11 
12export function getCurrentTenant(): TenantInfo {
13 const tenant = tenantStorage.getStore();
14 if (!tenant) {
15 throw new Error('No tenant context. Ensure TenantMiddleware is applied.');
16 }
17 return tenant;
18}
19 
20export function runWithTenant<T>(tenant: TenantInfo, fn: () => T): T {
21 return tenantStorage.run(tenant, fn);
22}
23 

The readonly modifiers on TenantInfo prevent accidental mutation after the context is set. The union type on plan ensures only valid plan identifiers are used throughout the codebase.

NestJS Middleware for Tenant Resolution

In a NestJS application, implement tenant resolution as a global middleware:

typescript
1import { Injectable, NestMiddleware, HttpException, HttpStatus } from '@nestjs/common';
2import { Request, Response, NextFunction } from 'express';
3 
4@Injectable()
5export class TenantMiddleware implements NestMiddleware {
6 constructor(private readonly tenantService: TenantService) {}
7 
8 async use(req: Request, res: Response, next: NextFunction) {
9 const tenantIdentifier =
10 req.headers['x-tenant-id'] as string ||
11 this.extractSubdomain(req.hostname);
12 
13 if (!tenantIdentifier) {
14 throw new HttpException('Tenant identification required', HttpStatus.BAD_REQUEST);
15 }
16 
17 const tenant = await this.tenantService.resolve(tenantIdentifier);
18 if (!tenant) {
19 throw new HttpException('Tenant not found', HttpStatus.NOT_FOUND);
20 }
21 
22 if (!tenant.isActive) {
23 throw new HttpException('Tenant account suspended', HttpStatus.FORBIDDEN);
24 }
25 
26 const tenantInfo: TenantInfo = {
27 tenantId: tenant.id,
28 plan: tenant.plan,
29 dbSchema: tenant.schemaName,
30 isIsolated: tenant.hasDedicatedDb,
31 };
32 
33 runWithTenant(tenantInfo, () => next());
34 }
35 
36 private extractSubdomain(hostname: string): string | null {
37 const parts = hostname.split('.');
38 return parts.length >= 3 ? parts[0] : null;
39 }
40}
41 

Register it globally in your app module:

typescript
1import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
2 
3@Module({ /* ... */ })
4export class AppModule implements NestModule {
5 configure(consumer: MiddlewareConsumer) {
6 consumer
7 .apply(TenantMiddleware)
8 .exclude('/health', '/metrics')
9 .forRoutes('*');
10 }
11}
12 

Branded Types for Tenant-Scoped IDs

Use TypeScript's structural typing to create branded types that prevent mixing tenant-scoped and unscoped identifiers:

typescript
1declare const __brand: unique symbol;
2 
3type Brand<T, B extends string> = T & { readonly [__brand]: B };
4 
5type TenantScopedId = Brand<string, 'TenantScoped'>;
6type GlobalId = Brand<string, 'Global'>;
7 
8function asTenantScoped(id: string): TenantScopedId {
9 const tenant = getCurrentTenant();
10 return `${tenant.tenantId}::${id}` as TenantScopedId;
11}
12 
13function asGlobalId(id: string): GlobalId {
14 return id as GlobalId;
15}
16 
17// This prevents accidentally passing a global ID where a scoped one is expected
18function getProjectByTenantId(id: TenantScopedId): Promise<Project> {
19 // ...
20}
21 
22// Compiler error: Argument of type 'string' is not assignable to parameter of type 'TenantScopedId'
23// getProjectByTenantId("some-id");
24 
25// Correct usage:
26// getProjectByTenantId(asTenantScoped("some-id"));
27 

Prisma Multi-Tenant Setup

Prisma is the most popular TypeScript ORM. Here is how to implement tenant isolation with it:

typescript
1import { PrismaClient, Prisma } from '@prisma/client';
2 
3function createTenantPrismaClient(): PrismaClient {
4 const prisma = new PrismaClient();
5 
6 return prisma.$extends({
7 query: {
8 $allModels: {
9 async findMany({ model, operation, args, query }) {
10 const tenant = getCurrentTenant();
11 args.where = { ...args.where, tenantId: tenant.tenantId };
12 return query(args);
13 },
14 async findFirst({ model, operation, args, query }) {
15 const tenant = getCurrentTenant();
16 args.where = { ...args.where, tenantId: tenant.tenantId };
17 return query(args);
18 },
19 async findUnique({ model, operation, args, query }) {
20 const tenant = getCurrentTenant();
21 const result = await query(args);
22 if (result && (result as any).tenantId !== tenant.tenantId) {
23 return null; // Cross-tenant access blocked
24 }
25 return result;
26 },
27 async create({ model, operation, args, query }) {
28 const tenant = getCurrentTenant();
29 args.data = { ...args.data, tenantId: tenant.tenantId };
30 return query(args);
31 },
32 async update({ model, operation, args, query }) {
33 const tenant = getCurrentTenant();
34 args.where = { ...args.where, tenantId: tenant.tenantId } as any;
35 return query(args);
36 },
37 async delete({ model, operation, args, query }) {
38 const tenant = getCurrentTenant();
39 args.where = { ...args.where, tenantId: tenant.tenantId } as any;
40 return query(args);
41 },
42 },
43 },
44 }) as unknown as PrismaClient;
45}
46 
47export const prisma = createTenantPrismaClient();
48 

Database-Per-Tenant Connection Routing

For enterprise tenants with dedicated databases, implement a connection pool manager:

typescript
1import { PrismaClient } from '@prisma/client';
2import { LRUCache } from 'lru-cache';
3 
4class DatabaseRouter {
5 private connectionPool: LRUCache<string, PrismaClient>;
6 private defaultClient: PrismaClient;
7 
8 constructor(
9 defaultUrl: string,
10 private tenantDbMap: Map<string, string>,
11 ) {
12 this.defaultClient = new PrismaClient({ datasourceUrl: defaultUrl });
13 this.connectionPool = new LRUCache<string, PrismaClient>({
14 max: 50,
15 dispose: async (client) => {
16 await client.$disconnect();
17 },
18 ttl: 1000 * 60 * 30, // 30 minutes
19 });
20 }
21 
22 getClient(): PrismaClient {
23 const tenant = getCurrentTenant();
24 
25 if (!tenant.isIsolated) {
26 return this.defaultClient;
27 }
28 
29 const dbUrl = this.tenantDbMap.get(tenant.tenantId);
30 if (!dbUrl) {
31 return this.defaultClient;
32 }
33 
34 let client = this.connectionPool.get(tenant.tenantId);
35 if (!client) {
36 client = new PrismaClient({ datasourceUrl: dbUrl });
37 this.connectionPool.set(tenant.tenantId, client);
38 }
39 return client;
40 }
41 
42 async shutdown(): Promise<void> {
43 await this.defaultClient.$disconnect();
44 for (const [, client] of this.connectionPool.entries()) {
45 await client.$disconnect();
46 }
47 }
48}
49 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Schema-Per-Tenant with Raw SQL

For PostgreSQL schema-per-tenant isolation, set the search path before each operation:

typescript
1import { PrismaClient } from '@prisma/client';
2 
3async function withTenantSchema<T>(
4 prisma: PrismaClient,
5 fn: (tx: PrismaClient) => Promise<T>,
6): Promise<T> {
7 const tenant = getCurrentTenant();
8 
9 return prisma.$transaction(async (tx) => {
10 await tx.$executeRawUnsafe(
11 `SET search_path TO "${tenant.dbSchema}", public`
12 );
13 return fn(tx as unknown as PrismaClient);
14 });
15}
16 
17// Usage
18async function listProjects(prisma: PrismaClient) {
19 return withTenantSchema(prisma, async (tx) => {
20 return tx.project.findMany({
21 orderBy: { createdAt: 'desc' },
22 take: 50,
23 });
24 });
25}
26 

Tenant-Aware Caching Layer

Build a caching layer that namespaces all keys by tenant:

typescript
1import { Redis } from 'ioredis';
2 
3class TenantCache {
4 constructor(
5 private redis: Redis,
6 private defaultTtl: number = 300,
7 ) {}
8 
9 private buildKey(namespace: string, key: string): string {
10 const tenant = getCurrentTenant();
11 return `t:${tenant.tenantId}:${namespace}:${key}`;
12 }
13 
14 async get<T>(namespace: string, key: string): Promise<T | null> {
15 const cacheKey = this.buildKey(namespace, key);
16 const value = await this.redis.get(cacheKey);
17 return value ? JSON.parse(value) : null;
18 }
19 
20 async set<T>(namespace: string, key: string, value: T, ttl?: number): Promise<void> {
21 const cacheKey = this.buildKey(namespace, key);
22 await this.redis.setex(cacheKey, ttl ?? this.defaultTtl, JSON.stringify(value));
23 }
24 
25 async invalidateTenant(tenantId: string): Promise<void> {
26 const stream = this.redis.scanStream({ match: `t:${tenantId}:*`, count: 100 });
27 for await (const keys of stream) {
28 if (keys.length > 0) {
29 await this.redis.del(...keys);
30 }
31 }
32 }
33}
34 

Plan-Based Rate Limiting

Implement rate limiting that respects tenant plan tiers:

typescript
1import { Redis } from 'ioredis';
2 
3interface PlanLimits {
4 requestsPerMinute: number;
5 burst: number;
6}
7 
8const PLAN_LIMITS: Record<string, PlanLimits> = {
9 free: { requestsPerMinute: 60, burst: 10 },
10 pro: { requestsPerMinute: 600, burst: 50 },
11 enterprise: { requestsPerMinute: 6000, burst: 200 },
12};
13 
14class TenantRateLimiter {
15 constructor(private redis: Redis) {}
16 
17 async checkLimit(endpoint: string): Promise<{ allowed: boolean; headers: Record<string, string> }> {
18 const tenant = getCurrentTenant();
19 const limits = PLAN_LIMITS[tenant.plan] ?? PLAN_LIMITS.free;
20 const key = `rl:${tenant.tenantId}:${endpoint}`;
21 const now = Date.now();
22 const window = 60_000;
23 
24 const pipe = this.redis.pipeline();
25 pipe.zremrangebyscore(key, 0, now - window);
26 pipe.zadd(key, now, `${now}-${Math.random()}`);
27 pipe.zcard(key);
28 pipe.pexpire(key, window);
29 const results = await pipe.exec();
30 
31 const count = results?.[2]?.[1] as number ?? 0;
32 const allowed = count <= limits.requestsPerMinute;
33 
34 return {
35 allowed,
36 headers: {
37 'X-RateLimit-Limit': String(limits.requestsPerMinute),
38 'X-RateLimit-Remaining': String(Math.max(0, limits.requestsPerMinute - count)),
39 'X-RateLimit-Reset': String(Math.ceil((now + window) / 1000)),
40 },
41 };
42 }
43}
44 

Tenant Provisioning Service

Automate tenant onboarding with a provisioning service:

typescript
1import { PrismaClient } from '@prisma/client';
2import { randomUUID } from 'node:crypto';
3 
4interface CreateTenantInput {
5 name: string;
6 plan: 'free' | 'pro' | 'enterprise';
7 adminEmail: string;
8}
9 
10class TenantProvisioningService {
11 constructor(
12 private prisma: PrismaClient,
13 private schemaManager: SchemaManager,
14 ) {}
15 
16 async createTenant(input: CreateTenantInput) {
17 const tenantId = randomUUID();
18 const schemaName = input.plan === 'enterprise'
19 ? `tenant_${tenantId.replace(/-/g, '_')}`
20 : 'shared';
21 
22 const tenant = await this.prisma.$transaction(async (tx) => {
23 const tenant = await tx.tenant.create({
24 data: {
25 id: tenantId,
26 name: input.name,
27 plan: input.plan,
28 schemaName,
29 isActive: true,
30 hasDedicatedDb: false,
31 },
32 });
33 
34 await tx.tenantUser.create({
35 data: {
36 tenantId: tenant.id,
37 email: input.adminEmail,
38 role: 'admin',
39 },
40 });
41 
42 return tenant;
43 });
44 
45 if (input.plan === 'enterprise') {
46 await this.schemaManager.provisionSchema(tenantId);
47 }
48 
49 return tenant;
50 }
51 
52 async deprovisionTenant(tenantId: string): Promise<void> {
53 const tenant = await this.prisma.tenant.findUniqueOrThrow({
54 where: { id: tenantId },
55 });
56 
57 await this.prisma.tenant.update({
58 where: { id: tenantId },
59 data: { isActive: false, deactivatedAt: new Date() },
60 });
61 
62 if (tenant.schemaName !== 'shared') {
63 await this.schemaManager.dropSchema(tenantId);
64 }
65 }
66}
67 

Testing Tenant Isolation

Write tests that verify cross-tenant boundaries are enforced:

typescript
1import { describe, it, expect, beforeEach } from 'vitest';
2 
3describe('Tenant Isolation', () => {
4 const tenantA: TenantInfo = {
5 tenantId: 'tenant-a',
6 plan: 'pro',
7 dbSchema: 'public',
8 isIsolated: false,
9 };
10 
11 const tenantB: TenantInfo = {
12 tenantId: 'tenant-b',
13 plan: 'pro',
14 dbSchema: 'public',
15 isIsolated: false,
16 };
17 
18 it('tenant A cannot see tenant B data', async () => {
19 await runWithTenant(tenantA, async () => {
20 await prisma.project.create({
21 data: { name: 'Tenant A Project' },
22 });
23 });
24 
25 await runWithTenant(tenantB, async () => {
26 const projects = await prisma.project.findMany();
27 expect(projects).toHaveLength(0);
28 });
29 });
30 
31 it('tenant B cannot update tenant A records', async () => {
32 let projectId: string;
33 await runWithTenant(tenantA, async () => {
34 const project = await prisma.project.create({
35 data: { name: 'Original' },
36 });
37 projectId = project.id;
38 });
39 
40 await runWithTenant(tenantB, async () => {
41 const result = await prisma.project.findFirst({
42 where: { id: projectId! },
43 });
44 expect(result).toBeNull();
45 });
46 });
47 
48 it('throws when no tenant context is set', () => {
49 expect(() => getCurrentTenant()).toThrow('No tenant context');
50 });
51 
52 it('cache keys are isolated between tenants', async () => {
53 const cache = new TenantCache(redis);
54 
55 await runWithTenant(tenantA, async () => {
56 await cache.set('projects', 'list', [{ id: 'a' }]);
57 });
58 
59 await runWithTenant(tenantB, async () => {
60 const result = await cache.get('projects', 'list');
61 expect(result).toBeNull();
62 });
63 });
64});
65 

Conclusion

TypeScript's type system gives you compile-time guarantees that dynamic languages cannot match for multi-tenant architecture. Branded types prevent cross-tenant ID usage, strict interfaces ensure tenant context is always present, and the AsyncLocalStorage API provides safe context propagation across async boundaries.

The layered approach — type safety at compile time, ORM-level filtering at runtime, and PostgreSQL RLS at the database level — creates defense in depth. Each layer catches issues the others might miss. Pair this with comprehensive isolation tests and per-tenant monitoring, and you have a multi-tenant system that scales from 10 tenants to 10,000 without architectural changes.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026