How to Build a Multi-Tenant SaaS Backend with Node.js
Pick the wrong multi-tenancy strategy at the start of a SaaS project and you will be refactoring database access code six months later — under pressure, with real customer data at stake. The decision is not merely architectural taste; it has direct consequences on isolation guarantees, operational cost, and how fast you can onboard new tenants.
This guide cuts through the theory and gives you a concrete decision framework plus a working row-level security (RLS) implementation in PostgreSQL that you can drop into a Node.js backend today.
The Three Multi-Tenancy Models
Before writing a single line of code, you need to choose one of three data isolation models:
- Database-per-tenant — every tenant gets their own PostgreSQL database.
- Schema-per-tenant — all tenants share one database, but each has a dedicated schema (e.g.,
tenant_acme.orders,tenant_beta.orders). - Shared schema — all tenants share the same tables; every row carries a
tenant_idforeign key.
Decision Tree
Use this as a starting checklist:
| Question | If YES → | If NO → |
|---|---|---|
| Do tenants have strict regulatory isolation requirements (HIPAA, GDPR data residency)? | Database-per-tenant | Continue |
| Will you exceed 50+ tenants within 12 months? | Shared schema + RLS | Continue |
| Do tenants need custom columns or schema extensions? | Schema-per-tenant | Shared schema + RLS |
For the vast majority of B2B SaaS products — especially in early growth — shared schema with row-level security is the sweet spot. It is operationally lean, scales horizontally, and PostgreSQL's native RLS makes the isolation story robust without application-layer gymnastics.
Setting Up Row-Level Security in PostgreSQL
PostgreSQL has had RLS since version 9.5. It lets you attach security policies directly to a table so that even a SELECT * only returns rows the current session is allowed to see.
Step 1 — Add the tenant_id Column
-- migrations/001_add_tenant_id.sql
ALTER TABLE orders ADD COLUMN tenant_id UUID NOT NULL;
ALTER TABLE users ADD COLUMN tenant_id UUID NOT NULL;
-- Index is critical for performance at scale
CREATE INDEX idx_orders_tenant_id ON orders(tenant_id);
CREATE INDEX idx_users_tenant_id ON users(tenant_id);
Step 2 — Enable RLS and Create Policies
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON orders
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
FORCE ROW LEVEL SECURITY ensures even the table owner is subject to the policy — a critical detail most tutorials omit. Without it, the database superuser or the role that owns the table bypasses RLS entirely, leaving a silent privilege escalation path open.
Step 3 — Set the Tenant Context in Node.js
The policy references app.current_tenant_id, a session-level configuration variable. You set it at the start of every request using a middleware that wraps queries in a transaction:
// src/middleware/tenantContext.js
import { pool } from '../db/pool.js';
export async function tenantContext(req, res, next) {
const tenantId = req.user?.tenantId; // extracted from verified JWT
if (!tenantId) {
return res.status(401).json({ error: 'Missing tenant context' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`SELECT set_config('app.current_tenant_id', $1, true)`,
[tenantId]
);
// Attach the client to the request so route handlers reuse the same connection
req.dbClient = client;
res.on('finish', async () => {
await client.query('COMMIT');
client.release();
});
next();
} catch (err) {
await client.query('ROLLBACK');
client.release();
next(err);
}
}
A few things worth noting here:
set_config('app.current_tenant_id', $1, true)— the third argumenttruescopes the variable to the current transaction. This prevents the value from leaking into a recycled connection from the pool.- The middleware grabs a single client and attaches it to
req. Route handlers must usereq.dbClientinstead of callingpool.query()directly; otherwise they spin up a fresh connection whereapp.current_tenant_idis not set.
Structuring Your Node.js Application
JWT Claims as the Tenant Source of Truth
Your JWT should encode tenantId as a claim at login time. Never trust a tenant_id value passed directly in the request body or query string — an attacker can trivially forge it. The middleware above reads from req.user, which is populated by your JWT verification middleware before tenantContext runs.
Connection Pooling Considerations
Row-level security works inside transactions, which means tenant context is inherently short-lived and safe in a shared pool. Use pg or Knex.js with a pool size proportional to your CPU cores on the database server — a rule of thumb is (2 × core_count) + effective_spindle_count. Oversizing the pool is a common mistake that increases memory pressure and context-switching on Postgres.
Testing Tenant Isolation
Write an explicit integration test that logs in as Tenant A, inserts a row, then queries as Tenant B and asserts an empty result set. This test should live in your CI pipeline and fail loudly if the RLS policy is ever accidentally dropped or altered. Isolation bugs discovered in production are catastrophic; isolation bugs caught in CI are just bugs.
When to Revisit the Strategy
Shared schema with RLS handles thousands of tenants comfortably, but watch for these signals that it is time to reconsider:
- Query performance degrades despite indexing — at extreme tenant-row ratios, partial indexes per tenant or table partitioning by
tenant_idcan recover performance. - A tenant demands dedicated infrastructure — offer schema-per-tenant or database-per-tenant as a premium tier without rewriting your core application. Keep the shared-schema version as the default.
- Compliance audits require physical separation — no amount of RLS cleverness satisfies an auditor who wants a separate database instance. Plan the upgrade path early.
Why This Matters for Your Project
Multi-tenancy is not a feature you bolt on later. The schema design, the connection management, and the security boundary all interact in ways that become expensive to change once customer data is live. Starting with shared schema and PostgreSQL RLS gives you a production-grade isolation model, operational simplicity, and the flexibility to promote individual tenants to dedicated schemas or databases as your product matures — without rewriting your entire data access layer.





