Building a multi-tenant API is one of the most critical architectural decisions you'll make when shipping a SaaS product. Get it right and you can scale to thousands of customers on shared infrastructure. Get it wrong and you're looking at data leaks, compliance nightmares, and re-architecting under pressure.
This guide walks through a complete, production-ready multi-tenant API in Node.js — covering tenant identification, database isolation strategies, per-tenant rate limiting, and feature gating. Everything is based on patterns battle-tested in 2026 SaaS stacks.
What Is Multi-Tenancy and Why Does It Matter?
Multi-tenancy means a single instance of your API serves multiple customers (tenants) — each with their own isolated data, config, and usage limits. Instead of spinning up a separate deployment per customer (which is costly and operationally painful), you share infrastructure while keeping each tenant's data strictly separated.
The three mainstream isolation models in 2026 are:
| Strategy | Isolation Level | Cost | Complexity |
|---|---|---|---|
Shared schema (single DB, tenant_id column) |
Logical | Low | Low |
| Schema-per-tenant (separate Postgres schemas) | Medium | Medium | Medium |
| Database-per-tenant | Physical | High | High |
For most SaaS APIs serving SMB customers, shared schema + PostgreSQL Row-Level Security (RLS) is the sweet spot. It's what Notion, Linear, and many modern SaaS tools use internally.
Project Setup
npm init -y
npm install express pg ioredis zod jsonwebtoken dotenv
npm install -D typescript @types/express @types/node tsx
Project structure:
src/
middleware/
tenant.ts # Tenant identification + context
rateLimiter.ts # Per-tenant rate limiting
db/
pool.ts # PostgreSQL connection pool
rls.ts # RLS session variable helper
routes/
api.ts # Protected routes
types.ts # Tenant types
index.ts
Step 1: Tenant Identification Middleware
The first thing every request needs to do is resolve which tenant it belongs to. In 2026, there are three common strategies — and a production API should support all three:
-
Subdomain:
acme.yourapi.com→ tenant slugacme -
Header:
X-Tenant-ID: tenant_abc123 -
JWT claim: Decoded from
Authorization: Bearer <token>
// src/middleware/tenant.ts
import { Request, Response, NextFunction } from 'express';
import { pool } from '../db/pool';
export interface TenantContext {
id: string; // UUID
slug: string; // e.g. "acme"
plan: 'free' | 'pro' | 'enterprise';
features: string[]; // enabled feature flags
rateLimit: number; // requests/minute
}
declare global {
namespace Express {
interface Request {
tenant: TenantContext;
}
}
}
async function resolveTenant(identifier: string, type: 'id' | 'slug'): Promise<TenantContext | null> {
const column = type === 'id' ? 'id' : 'slug';
const { rows } = await pool.query(
`SELECT id, slug, plan, features, rate_limit
FROM tenants
WHERE ${column} = $1 AND active = true`,
[identifier]
);
return rows[0] ?? null;
}
export function tenantMiddleware() {
return async (req: Request, res: Response, next: NextFunction) => {
try {
let tenant: TenantContext | null = null;
// Strategy 1: Subdomain (acme.api.yourapp.com)
const host = req.hostname;
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'api' && subdomain !== 'www') {
tenant = await resolveTenant(subdomain, 'slug');
}
// Strategy 2: X-Tenant-ID header (API key flows)
if (!tenant && req.headers['x-tenant-id']) {
tenant = await resolveTenant(req.headers['x-tenant-id'] as string, 'id');
}
// Strategy 3: JWT claim (user-facing flows)
if (!tenant && req.headers.authorization) {
const token = req.headers.authorization.replace('Bearer ', '');
try {
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
if (decoded.tenant_id) {
tenant = await resolveTenant(decoded.tenant_id, 'id');
}
} catch {
// Invalid token — let auth middleware handle it
}
}
if (!tenant) {
return res.status(400).json({
error: 'tenant_required',
message: 'Could not resolve tenant from request'
});
}
req.tenant = tenant;
next();
} catch (err) {
next(err);
}
};
}
Important: Cache tenant lookups in Redis with a short TTL (30–60s). In a high-traffic API, hitting the DB on every request to resolve the tenant kills performance.
// Cached version with Redis
async function resolveTenantCached(identifier: string, type: 'id' | 'slug'): Promise<TenantContext | null> {
const cacheKey = `tenant:${type}:${identifier}`;
const redis = getRedis();
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const tenant = await resolveTenant(identifier, type);
if (tenant) {
await redis.setex(cacheKey, 60, JSON.stringify(tenant)); // 60s TTL
}
return tenant;
}
Step 2: PostgreSQL Row-Level Security (RLS)
RLS is a PostgreSQL feature that enforces data isolation at the database engine level — meaning even if your application code has a bug and forgets to filter by tenant_id, the database will still block cross-tenant access.
Set up the schema:
-- Create tenants table
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT UNIQUE NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
features TEXT[] DEFAULT '{}',
rate_limit INTEGER DEFAULT 100,
active BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Example: a documents table with tenant isolation
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
title TEXT NOT NULL,
content TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create a restricted DB role for the app
CREATE ROLE api_user LOGIN PASSWORD 'your_secure_password';
GRANT CONNECT ON DATABASE saas_db TO api_user;
GRANT USAGE ON SCHEMA public TO api_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO api_user;
-- Enable RLS on all tenant-scoped tables
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
-- Create isolation policy
CREATE POLICY tenant_isolation ON documents
FOR ALL
TO api_user
USING (tenant_id = current_setting('app.current_tenant')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Set the session variable before each query:
// src/db/rls.ts
import { Pool, PoolClient } from 'pg';
export async function withTenantContext<T>(
pool: Pool,
tenantId: string,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// Set the tenant context for RLS
await client.query(`SET LOCAL app.current_tenant = '${tenantId}'`);
const result = await fn(client);
return result;
} finally {
client.release();
}
}
Usage in a route:
// src/routes/api.ts
import { Router } from 'express';
import { withTenantContext } from '../db/rls';
import { pool } from '../db/pool';
const router = Router();
router.get('/documents', async (req, res) => {
const { tenant } = req;
const documents = await withTenantContext(pool, tenant.id, async (client) => {
// RLS automatically filters by tenant_id — no WHERE clause needed
const { rows } = await client.query(
`SELECT id, title, created_at FROM documents ORDER BY created_at DESC LIMIT 50`
);
return rows;
});
res.json({ documents, tenant: tenant.slug });
});
router.post('/documents', async (req, res) => {
const { tenant } = req;
const { title, content } = req.body;
const doc = await withTenantContext(pool, tenant.id, async (client) => {
const { rows } = await client.query(
`INSERT INTO documents (tenant_id, title, content) VALUES ($1, $2, $3) RETURNING *`,
[tenant.id, title, content]
);
return rows[0];
});
res.status(201).json(doc);
});
export default router;
With this setup, a query from tenant A cannot return data from tenant B — even if there's a code bug. The database enforces it.
Step 3: Per-Tenant Rate Limiting
Different plans get different rate limits. Free-tier tenants get 100 req/min, Pro gets 1,000, Enterprise gets 10,000 — all enforced with a Redis token bucket.
// src/middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL!);
export function perTenantRateLimit() {
return async (req: Request, res: Response, next: NextFunction) => {
const { tenant } = req;
const key = `ratelimit:${tenant.id}`;
const limit = tenant.rateLimit; // e.g. 100 for free, 1000 for pro
const window = 60; // seconds
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.ttl(key);
const results = await pipeline.exec();
const count = results![0][1] as number;
const ttl = results![1][1] as number;
if (ttl === -1) {
await redis.expire(key, window);
}
const remaining = Math.max(0, limit - count);
const resetAt = ttl > 0 ? ttl : window;
// Set standard rate limit headers
res.setHeader('X-RateLimit-Limit', limit);
res.setHeader('X-RateLimit-Remaining', remaining);
res.setHeader('X-RateLimit-Reset', resetAt);
res.setHeader('X-Tenant-Plan', tenant.plan);
if (count > limit) {
return res.status(429).json({
error: 'rate_limit_exceeded',
message: `Rate limit of ${limit} req/min exceeded. Upgrade your plan for higher limits.`,
reset_in_seconds: resetAt
});
}
next();
};
}
Step 4: Feature Gating by Plan
Not all tenants get all features. Feature gating lets you progressively roll out capabilities:
// src/middleware/featureGate.ts
import { Request, Response, NextFunction } from 'express';
export function requireFeature(feature: string) {
return (req: Request, res: Response, next: NextFunction) => {
const { tenant } = req;
if (!tenant.features.includes(feature)) {
return res.status(403).json({
error: 'feature_not_available',
message: `The '${feature}' feature is not available on the ${tenant.plan} plan.`,
upgrade_url: 'https://1xapi.com/pricing'
});
}
next();
};
}
Usage — gate specific endpoints by feature flag:
// Only pro/enterprise tenants can access analytics
router.get('/analytics',
requireFeature('analytics'),
async (req, res) => {
// ...
}
);
// Only enterprise tenants get custom webhooks
router.post('/webhooks',
requireFeature('custom_webhooks'),
async (req, res) => {
// ...
}
);
Step 5: Wiring It All Together
// index.ts
import express from 'express';
import { tenantMiddleware } from './src/middleware/tenant';
import { perTenantRateLimit } from './src/middleware/rateLimiter';
import apiRouter from './src/routes/api';
const app = express();
app.use(express.json());
// Global tenant resolution
app.use('/api', tenantMiddleware());
// Per-tenant rate limiting (after tenant is resolved)
app.use('/api', perTenantRateLimit());
// Protected API routes
app.use('/api/v1', apiRouter);
// Error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error({ err, tenant: req.tenant?.slug });
res.status(500).json({ error: 'internal_server_error' });
});
app.listen(3000, () => console.log('Multi-tenant API running on :3000'));
Testing Tenant Isolation
Always write explicit cross-tenant isolation tests:
// test/isolation.test.ts
import { describe, it, expect, beforeAll } from 'vitest';
describe('Tenant isolation', () => {
let tenantAToken: string;
let tenantBToken: string;
let tenantADocId: string;
beforeAll(async () => {
// Create test tenants and get tokens
tenantAToken = await createTestTenant('tenant-a');
tenantBToken = await createTestTenant('tenant-b');
});
it('Tenant A can create and read their own documents', async () => {
const res = await fetch('/api/v1/documents', {
method: 'POST',
headers: {
'Authorization': `Bearer ${tenantAToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ title: 'Tenant A doc' })
});
expect(res.status).toBe(201);
const doc = await res.json();
tenantADocId = doc.id;
});
it('Tenant B CANNOT access Tenant A documents', async () => {
const res = await fetch(`/api/v1/documents`, {
headers: { 'Authorization': `Bearer ${tenantBToken}` }
});
const { documents } = await res.json();
const leaked = documents.find((d: any) => d.id === tenantADocId);
expect(leaked).toBeUndefined(); // RLS blocks it at the DB level
});
});
Performance Considerations
Running a multi-tenant API at scale has a few gotchas:
Connection pooling: With RLS, each query needs SET LOCAL app.current_tenant — this must run on the same connection. Use pgBouncer in transaction mode (not session mode) and always call SET LOCAL inside a transaction block.
Noisy neighbor: A Free-tier tenant hammering your API shouldn't degrade Pro tenants. Per-tenant rate limiting (Step 3) handles request volume, but also consider per-tenant query timeouts:
-- Set per-tenant statement timeout in the session
SET LOCAL statement_timeout = '5s';
Tenant onboarding: When a new tenant signs up, use a queue (BullMQ, Inngest) to run their schema migrations asynchronously — don't block the signup response.
What's Next
This pattern scales well to thousands of tenants. When you hit the limits of shared schema (usually around 10M+ rows per table), migrate high-value tenants to dedicated schemas or databases without changing the API surface — the middleware handles routing transparently.
For teams building APIs that need to be consumed across multiple organizations, explore the 1xAPI marketplace for pre-built API building blocks — authentication, rate limiting, and tenant management primitives you can plug in without starting from scratch.
Top comments (0)