Building a Multi-Tenant SaaS App with Node.js: Architecture, Isolation, and the Mistakes to Avoid
The three isolation models, Postgres Row Level Security, and the architecture decisions you cannot retrofit later
Senior Developer

Multitenancy is one of those architectural decisions that is nearly impossible to retrofit. If you build a single-tenant app and decide later to make it multi-tenant, you are essentially rebuilding the data model from scratch. If you build multi-tenant from day one, you get hardware utilization improvements of over 60% compared to single-tenancy — and a product that can serve thousands of customers without provisioning a separate stack for each one.
This guide covers the three isolation models, how to choose between them, and the full implementation of the most common pattern for early-stage SaaS: shared database with row-level tenant isolation.
The Three Isolation Models
Silo (separate database per tenant) — Each customer gets their own database. Maximum isolation, easiest compliance (GDPR, HIPAA), simplest queries (no tenant filtering needed). Expensive to operate at scale — provisioning, migrations, and monitoring multiply by the number of tenants.
Pool (shared database, separate schemas) — Each tenant gets their own Postgres schema (tenant_a.users, tenant_b.users). Good isolation, still manageable, migrations are slightly harder. Postgres supports this well with search_path.
Bridge (shared database, shared tables with tenant ID) — All tenants share tables, every row has a tenant_id column. Cheapest to operate, hardest to get right. Requires strict query discipline and row-level security to prevent data leaks between tenants. This is what most early-stage SaaS apps should use.
This guide implements the Bridge model — the right choice until you have compliance requirements or enterprise customers demanding dedicated infrastructure.
The Database Schema
Every table that holds tenant data needs a tenant_id foreign key. No exceptions.
-- The tenants table is the root of everything
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE, -- Used in URLs: app.com/t/acme-corp
name TEXT NOT NULL,
plan TEXT NOT NULL DEFAULT 'free',
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Users belong to tenants
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- 'owner', 'admin', 'member'
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, email) -- Email unique per tenant, not globally
);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
-- Every business table follows the same pattern
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);Row-Level Security: The Safety Net
Even with disciplined application code, a bug could accidentally expose one tenant's data to another. Postgres Row Level Security (RLS) is a database-level guarantee that prevents this, regardless of what your application does.
-- Enable RLS on every tenant-scoped table
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create a policy: only rows matching the current tenant context are visible
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Your app user needs to bypass RLS for admin operations
-- Create a separate superuser role for migrations only
-- The app user should NOT have BYPASSRLSBefore every query, your app sets the tenant context:
// src/lib/db.ts
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function withTenantContext<T>(
tenantId: string,
fn: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
// Set the tenant context for RLS
await client.query(
`SELECT set_config('app.current_tenant_id', $1, true)`,
[tenantId]
);
return await fn(client);
} finally {
client.release();
}
}With RLS enabled, even if a developer forgets to add WHERE tenant_id = ? to a query, Postgres enforces it at the database level. It is the difference between "we trust our developers to never make a mistake" and "we have a structural guarantee."
Tenant Resolution Middleware
Every request needs to resolve which tenant it belongs to. There are three common strategies:
Subdomain: acme.yourapp.com → tenant slug is acmePath prefix: yourapp.com/t/acme/dashboardJWT claim: tenant ID embedded in the auth token
Subdomain is the cleanest UX for B2B SaaS:
// src/middleware/tenantResolver.ts
import { db } from '../lib/db';
export async function resolveTenant(req, res, next) {
// Extract tenant from subdomain: acme.yourapp.com → 'acme'
const host = req.hostname;
const appDomain = process.env.APP_DOMAIN; // 'yourapp.com'
let tenantSlug: string | null = null;
if (host.endsWith(`.${appDomain}`)) {
tenantSlug = host.replace(`.${appDomain}`, '');
}
// Fallback: tenant slug in path /t/:slug/...
if (!tenantSlug && req.params.tenantSlug) {
tenantSlug = req.params.tenantSlug;
}
if (!tenantSlug || tenantSlug === 'www' || tenantSlug === 'api') {
return next(); // Public routes — no tenant needed
}
// Cache tenant lookups in Redis to avoid a DB hit on every request
const cacheKey = `tenant:slug:${tenantSlug}`;
let tenant = await redis.get(cacheKey).then(v => v ? JSON.parse(v) : null);
if (!tenant) {
const result = await db.query(
'SELECT id, slug, name, plan, is_active FROM tenants WHERE slug = $1',
[tenantSlug]
);
tenant = result.rows[0] || null;
if (tenant) {
// Cache for 5 minutes
await redis.setex(cacheKey, 300, JSON.stringify(tenant));
}
}
if (!tenant) {
return res.status(404).json({ error: 'Workspace not found' });
}
if (!tenant.is_active) {
return res.status(403).json({ error: 'Workspace suspended' });
}
req.tenant = tenant;
next();
}Tenant-Aware Authentication
Users belong to tenants. A user from tenant A should not be able to access tenant B, even with a valid JWT.
// src/middleware/authenticate.ts
export async function authenticate(req, res, next) {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
let payload;
try {
payload = verifyAccessToken(token);
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
// Critical: verify the token's tenant matches the request's tenant
if (req.tenant && payload.tenantId !== req.tenant.id) {
return res.status(403).json({ error: 'Token not valid for this workspace' });
}
req.user = { id: payload.sub, role: payload.role, tenantId: payload.tenantId };
next();
}When generating tokens, embed the tenant ID:
export function generateAccessToken(user: User, tenant: Tenant) {
return jwt.sign(
{
sub: user.id,
tenantId: tenant.id,
role: user.role,
type: 'access',
},
PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: '15m' }
);
}Tenant-Scoped Queries
With RLS active, every query automatically filters by the current tenant. But you still need to set the context before querying:
// src/routes/projects.ts
router.get('/', authenticate, async (req, res) => {
const projects = await withTenantContext(req.tenant.id, async (client) => {
// RLS automatically adds WHERE tenant_id = req.tenant.id
// You don't need to add it manually — but it's good practice to anyway
const result = await client.query(
'SELECT * FROM projects ORDER BY created_at DESC'
);
return result.rows;
});
res.json(projects);
});For your own sanity, always add WHERE tenant_id = $1 explicitly in your queries even though RLS enforces it. It makes the tenant scoping visible in code reviews and protects you if RLS is ever accidentally disabled.
Plan-Based Feature Flags
Multi-tenant SaaS usually means multiple plans. Keep plan enforcement in one place:
// src/lib/planLimits.ts
const PLAN_LIMITS = {
free: {
maxProjects: 3,
maxMembers: 5,
fileStorageMB: 100,
apiCallsPerDay: 1000,
},
pro: {
maxProjects: 50,
maxMembers: 25,
fileStorageMB: 10000,
apiCallsPerDay: 50000,
},
enterprise: {
maxProjects: Infinity,
maxMembers: Infinity,
fileStorageMB: Infinity,
apiCallsPerDay: Infinity,
},
} as const;
export async function checkPlanLimit(
tenantId: string,
plan: string,
resource: keyof typeof PLAN_LIMITS.free
) {
const limit = PLAN_LIMITS[plan]?.[resource] ?? 0;
if (limit === Infinity) return; // Enterprise — no limits
const current = await getCurrentUsage(tenantId, resource);
if (current >= limit) {
throw new PlanLimitError(
`${resource} limit reached for ${plan} plan (${limit}). Upgrade to continue.`
);
}
}// In your route handler
router.post('/projects', authenticate, async (req, res) => {
try {
await checkPlanLimit(req.tenant.id, req.tenant.plan, 'maxProjects');
} catch (err) {
if (err instanceof PlanLimitError) {
return res.status(403).json({ error: err.message, upgradeRequired: true });
}
throw err;
}
const project = await createProject(req.tenant.id, req.user.id, req.body);
res.status(201).json(project);
});Tenant Provisioning
When a new customer signs up, you create the tenant and the first user (the owner) atomically:
async function provisionTenant(data: {
tenantName: string;
ownerEmail: string;
ownerName: string;
password: string;
}) {
return await db.transaction(async (tx) => {
// Create tenant
const slug = slugify(data.tenantName) + '-' + randomBytes(3).toString('hex');
const [tenant] = await tx
.insert(tenants)
.values({ name: data.tenantName, slug })
.returning();
// Create owner
const passwordHash = await bcrypt.hash(data.password, 12);
const [owner] = await tx
.insert(users)
.values({
tenantId: tenant.id,
email: data.ownerEmail,
fullName: data.ownerName,
passwordHash,
role: 'owner',
})
.returning();
// Queue welcome email
await emailQueue.add('welcome', {
userId: owner.id,
tenantId: tenant.id,
tenantSlug: tenant.slug,
});
return { tenant, owner };
});
}The Mistakes That Cost the Most
Forgetting tenant_id on a table. One table without tenant scoping becomes a data leak waiting to happen. Audit every table at schema review time.
Not enabling RLS. Application-level tenant filtering is necessary but not sufficient. One missing WHERE clause in one query leaks data. RLS is the structural guarantee.
Caching data without tenant scope. cache:users:all across your entire app is a cross-tenant data leak. Every cache key must include the tenant ID: cache:${tenantId}:users.
Global rate limits instead of per-tenant limits. One tenant hammering your API should not affect another tenant's experience. Rate limit by tenantId, not just by IP.
Comments (0)
Login to post a comment.