How to Build a Multi-Tenant SaaS Backend With Node.js

Multi-tenancy is the architectural decision that separates a side project from a real SaaS product. Get it wrong early and you will spend months refactoring the thing that should have been invisible to your users all along. Get it right and you unlock the ability to onboard thousands of customers on a single deployment without ever mixing their data.

This article skips the whiteboard theory and goes straight to the decision you actually need to make — and then implements it.


The Core Choice: Row-Level vs. Schema-Level Isolation

Before writing a single line of code, you need to answer one question: how separate does each tenant's data need to be?

There are three common patterns:

  • Shared database, shared schema (row-level isolation) — all tenants live in the same tables, distinguished by a tenant_id column.
  • Shared database, separate schemas — each tenant gets their own PostgreSQL schema within one database.
  • Separate databases — each tenant gets their own database instance entirely.

Here is a practical decision tree:

Are you targeting enterprise customers with strict data-residency requirements?
  └─ Yes → Separate databases (or schema-level at minimum)
  └─ No → How many tenants do you expect at maturity?
            └─ < 500, low query complexity → Row-level isolation
            └─ > 500, or tenants need isolated migrations → Schema-level isolation

For most early-stage SaaS products, row-level isolation is the right starting point. It is simpler to implement, cheaper to operate, and easier to query across tenants for analytics. The main risk is accidentally leaking data between tenants if you forget a WHERE tenant_id = ? clause — which is exactly the bug we will guard against architecturally.


Project Setup

Start with a standard Express + PostgreSQL stack. We will use pg (node-postgres) directly rather than an ORM, so the SQL is explicit and the isolation logic is obvious.

npm init -y
npm install express pg dotenv
npm install --save-dev nodemon

Your .env should hold DATABASE_URL and nothing else sensitive in version control.


Designing the Tenant-Aware Schema

Every data table gets a tenant_id column. The tenants table is the source of truth.

CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name TEXT NOT NULL,
  subdomain TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE projects (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);

The index on tenant_id is not optional. Without it, every query that filters by tenant performs a full table scan — your p99 latency will quietly degrade as data grows.


Resolving the Tenant From the Request

Tenants are typically identified by subdomain (acme.yourapp.com) or by a JWT claim. Here is a middleware that resolves the tenant from the subdomain and attaches it to res.locals:

// middleware/resolveTenant.js
const { pool } = require('../db');

async function resolveTenant(req, res, next) {
  const host = req.hostname; // e.g. "acme.yourapp.com"
  const subdomain = host.split('.')[0];

  if (!subdomain || subdomain === 'www') {
    return res.status(400).json({ error: 'Tenant could not be resolved.' });
  }

  try {
    const { rows } = await pool.query(
      'SELECT id FROM tenants WHERE subdomain = $1 LIMIT 1',
      [subdomain]
    );

    if (!rows.length) {
      return res.status(404).json({ error: 'Tenant not found.' });
    }

    res.locals.tenantId = rows[0].id;
    next();
  } catch (err) {
    next(err);
  }
}

module.exports = resolveTenant;

Apply this middleware globally before any route that touches tenant data. Never pass tenantId as a user-supplied request parameter — always derive it server-side.


Building a Tenant-Scoped Query Helper

The most dangerous part of row-level isolation is developer forgetfulness. The fix is to make the safe path the only path. Create a thin query wrapper that always injects tenant_id:

// db/tenantQuery.js
const { pool } = require('./index');

async function tenantQuery(tenantId, text, params = []) {
  // Inject tenantId as the first parameter by convention
  const fullParams = [tenantId, ...params];
  return pool.query(text, fullParams);
}

module.exports = { tenantQuery };

Your route handlers then look like this:

// routes/projects.js
const { tenantQuery } = require('../db/tenantQuery');

router.get('/', async (req, res, next) => {
  try {
    const { rows } = await tenantQuery(
      res.locals.tenantId,
      'SELECT * FROM projects WHERE tenant_id = $1 ORDER BY created_at DESC',
    );
    res.json(rows);
  } catch (err) {
    next(err);
  }
});

This pattern does not prevent mistakes entirely, but it creates a visible, reviewable convention. Code reviewers know to check that every query in a route handler goes through tenantQuery.


When to Switch to Schema-Level Isolation

Schema-level isolation becomes worth the operational overhead when:

  • Tenants require independent migration schedules (enterprise clients who reject zero-downtime schema changes).
  • You are subject to GDPR or SOC 2 controls that require demonstrable data separation.
  • Individual tenant databases need to be backed up and restored independently.

The tradeoff is real: with schema-level isolation, cross-tenant analytics queries become painful (you need UNION ALL across schemas or a separate data warehouse pipeline), and your connection pool strategy changes significantly — you either use a single pool with SET search_path per request, or you maintain per-tenant pools (which becomes expensive past a few hundred tenants).


Hardening for Production

A few non-negotiable additions before you ship:

  • Rate limiting per tenant, not per IP — use express-rate-limit with a key generator based on res.locals.tenantId.
  • Audit logging — insert a record into an audit_log table on all mutations, including tenant_id, user_id, and the operation performed.
  • PostgreSQL Row-Level Security (RLS) as a second line of defense. Set SET app.current_tenant_id = $1 at the start of each transaction and enforce it at the database layer. This catches leaks that slip past application code.
  • Health checks that include tenant resolution — a canary that resolves a test tenant on every deployment confirms your middleware is wired correctly.

Why This Matters for Your Project

If you are building or scaling a SaaS product, the multi-tenancy model you choose on day one will constrain your pricing model, your compliance posture, and your infrastructure costs for years. Row-level isolation in PostgreSQL with a disciplined query wrapper gets you to hundreds of tenants with minimal operational burden. The architecture above is not just a tutorial skeleton — it is close to what production Node.js SaaS systems actually run. The right time to implement this correctly is before your second customer, not after your fiftieth.