Home Services Projects Blog Contact 📅 Schedule a Call
Home Blog Multi-Tenant SaaS
Web Dev

Building Multi-Tenant SaaS Architecture with Node.js

Multi-tenancy is the architectural pattern that separates a SaaS startup from a collection of separate apps. Get it right and you can onboard a thousand customers with the same codebase. Get it wrong and you spend months retrofitting isolation you should have built on day one — or worse, you ship a data breach because tenant A saw tenant B's records.

I've built multi-tenant systems in Node.js from scratch, and I've also inherited codebases where "multi-tenancy" meant a customerId column bolted on six months in. The difference in developer experience — and the security consequences — are enormous. This guide covers the decisions that matter before you write your first route handler.

"The time to think about tenant isolation is before your first customer, not after your tenth."

What Multi-Tenancy Actually Means

Multi-tenancy means a single deployed instance of your application serves multiple customers (tenants), with each tenant's data logically or physically isolated from all others. The "tenants" are companies or organizations — not individual users. A user belongs to a tenant; the tenant is your actual customer who pays you.

This is different from having many users. A consumer app with a million individual accounts is not multi-tenant. A B2B SaaS where 500 companies each have their own workspace, their own users, and their own data — that's multi-tenant.

The three things you must isolate per tenant:

  • Data — Tenant A cannot read, write, or even know about Tenant B's records
  • Configuration — Each tenant has their own settings, branding, feature flags, and integrations
  • Billing — Each tenant has an independent subscription and usage meter

The Three Database Isolation Models

Every multi-tenant database architecture falls into one of three models. The decision you make here shapes your infrastructure costs, compliance posture, and operational complexity for the life of the product.

Model 1: Shared Database, Shared Schema

All tenants live in the same tables, distinguished by a tenant_id column. This is the simplest model and the most common starting point.

AspectDetail
CostLowest — one database instance for all tenants
Operational complexitySimple — one schema to migrate, one connection pool
Isolation riskHigh — one missing WHERE clause exposes all tenant data
Compliance (GDPR/HIPAA)Difficult — hard to provide tenant-level data exports or deletion guarantees
PerformanceRisk of noisy neighbor — one large tenant can degrade others
Best forEarly-stage SaaS with homogenous tenants and <100 customers

Model 2: Shared Database, Schema Per Tenant

All tenants share one PostgreSQL database, but each has their own schema (e.g., tenant_acme.users, tenant_globex.users). Data is physically separate but infrastructure is shared.

AspectDetail
CostLow-medium — still one DB server, schemas are cheap
Operational complexityMedium — schema migrations must run per-tenant
Isolation riskMedium — schema search_path misconfiguration can cross tenants
ComplianceBetter — tenant data is physically separated in its own schema
PerformanceBetter isolation; still shares connection pool and compute
Best forMid-stage SaaS with 50–500 tenants needing schema-level isolation

Model 3: Database Per Tenant

Each tenant gets their own database instance, provisioned on demand. Maximum isolation, maximum cost.

AspectDetail
CostHigh — scales linearly with tenant count
Operational complexityHigh — migrations, backups, monitoring per database
Isolation riskNear zero — separate compute, network, and storage
ComplianceExcellent — data residency, deletion, and auditing per tenant
PerformanceFull dedicated resources per tenant; no noisy neighbors
Best forEnterprise SaaS with compliance requirements (HIPAA, SOC2, GDPR)

For most SaaS products I build or advise, I start with Shared DB + Row-Level Security (RLS). Here's why: the risk of the naive shared-DB model isn't the architecture itself — it's that developers forget to add WHERE tenant_id = ? to queries. PostgreSQL's RLS enforces that at the database level, making it impossible to query across tenants even if application code has a bug.

This gives you:

  • The cost and operational simplicity of a shared schema
  • Enforcement of tenant isolation that doesn't rely on developer discipline
  • A clear migration path to schema-per-tenant when you need it

Tenant Context Middleware

Every request in a multi-tenant system must carry tenant context. The cleanest pattern is Express middleware that resolves the tenant from the request (subdomain, JWT claim, or API key) and attaches it to req.tenant.

middleware/tenant.js// middleware/tenant.js
const { getTenantBySubdomain, getTenantByApiKey } = require('../services/tenantService');

async function tenantMiddleware(req, res, next) {
  try {
    let tenant = null;

    // Strategy 1: Subdomain routing (app.acme.yourproduct.com)
    const host = req.hostname;
    const subdomain = host.split('.')[0];
    if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
      tenant = await getTenantBySubdomain(subdomain);
    }

    // Strategy 2: API key header (for API-first SaaS)
    if (!tenant && req.headers['x-api-key']) {
      tenant = await getTenantByApiKey(req.headers['x-api-key']);
    }

    // Strategy 3: JWT claim (for web app sessions)
    if (!tenant && req.user?.tenantId) {
      tenant = { id: req.user.tenantId, slug: req.user.tenantSlug };
    }

    if (!tenant) {
      return res.status(401).json({ error: 'Tenant not resolved' });
    }

    req.tenant = tenant;
    // Set Postgres session variable for RLS
    await req.db.query(`SET app.current_tenant_id = '${tenant.id}'`);
    next();
  } catch (err) {
    next(err);
  }
}

module.exports = { tenantMiddleware };
Security Note

Never trust the tenant ID from the request body or query string. Always resolve tenant identity from a signed source: a verified JWT, a hashed API key, or a verified session. Tenant resolution is a security boundary, not just a routing concern.

Row-Level Security with PostgreSQL

RLS policies in PostgreSQL are evaluated for every query on a table, server-side, before any data is returned. Even if your application accidentally omits a WHERE tenant_id = ?, RLS will enforce it.

-- Enable RLS on every tenant-scoped table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

-- Create a policy that restricts reads and writes to the current tenant
CREATE POLICY tenant_isolation_policy ON projects
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

-- Apply the same pattern to all tenant-scoped tables
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON tasks
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

In your Node.js database layer, set the session variable before every query batch:

db/client.js// db/client.js — wrap pg Pool to inject tenant context
const { Pool } = require('pg');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });

async function withTenantContext(tenantId, callback) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await client.query(
      `SET LOCAL app.current_tenant_id = $1`,
      [tenantId]
    );
    const result = await callback(client);
    await client.query('COMMIT');
    return result;
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

module.exports = { pool, withTenantContext };

Tenant-Aware Repository Pattern

The repository pattern gives you a clean abstraction over your database layer. In a multi-tenant system, every repository should be tenant-scoped at construction time — the tenant ID is injected once, not passed to every method.

repositories/ProjectRepository.ts// repositories/ProjectRepository.ts
import { withTenantContext } from '../db/client';

interface Project {
  id: string;
  tenantId: string;
  name: string;
  createdAt: Date;
}

export class ProjectRepository {
  constructor(private readonly tenantId: string) {}

  async findAll(): Promise<Project[]> {
    return withTenantContext(this.tenantId, async (client) => {
      const { rows } = await client.query(
        'SELECT * FROM projects ORDER BY created_at DESC'
      );
      return rows;
    });
  }

  async findById(id: string): Promise<Project | null> {
    return withTenantContext(this.tenantId, async (client) => {
      const { rows } = await client.query(
        'SELECT * FROM projects WHERE id = $1', [id]
      );
      return rows[0] || null;
    });
  }

  async create(data: Omit<Project, 'id' | 'tenantId' | 'createdAt'>): Promise<Project> {
    return withTenantContext(this.tenantId, async (client) => {
      const { rows } = await client.query(
        `INSERT INTO projects (tenant_id, name)
         VALUES (current_setting('app.current_tenant_id')::uuid, $1)
         RETURNING *`,
        [data.name]
      );
      return rows[0];
    });
  }
}

// Usage in a route handler
// const repo = new ProjectRepository(req.tenant.id);
// const projects = await repo.findAll(); // automatically scoped to tenant

Handling Tenant Onboarding

Tenant onboarding is the provisioning flow that runs when a new customer signs up. It must be atomic — either all resources are created or none are. Use a database transaction and a dedicated onboarding service.

services/onboarding.js// services/onboarding.js
const { pool } = require('../db/client');
const { createStripeCustomer } = require('./billing');
const crypto = require('crypto');

async function provisionTenant({ companyName, adminEmail, plan }) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // 1. Create tenant record
    const slug = companyName.toLowerCase().replace(/[^a-z0-9]/g, '-');
    const { rows: [tenant] } = await client.query(
      `INSERT INTO tenants (name, slug, plan, status)
       VALUES ($1, $2, $3, 'provisioning') RETURNING *`,
      [companyName, slug, plan]
    );

    // 2. Create admin user
    const { rows: [adminUser] } = await client.query(
      `INSERT INTO users (tenant_id, email, role, invite_token)
       VALUES ($1, $2, 'admin', $3) RETURNING *`,
      [tenant.id, adminEmail, crypto.randomUUID()]
    );

    // 3. Create Stripe customer (outside transaction, but idempotent)
    const stripeCustomer = await createStripeCustomer({
      email: adminEmail,
      name: companyName,
      metadata: { tenantId: tenant.id }
    });

    // 4. Store Stripe customer ID
    await client.query(
      `UPDATE tenants SET stripe_customer_id = $1, status = 'active' WHERE id = $2`,
      [stripeCustomer.id, tenant.id]
    );

    await client.query('COMMIT');
    return { tenant, adminUser };
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Billing & Subscription per Tenant

Each tenant has an independent Stripe customer and subscription. The cleanest integration pattern is to listen to Stripe webhooks and keep your tenants table in sync with the subscription state. Never trust the client-side to tell you a payment succeeded.

routes/webhooks.js// routes/webhooks.js — Stripe webhook handler
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

router.post('/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  const { tenantId } = event.data.object.metadata;

  switch (event.type) {
    case 'customer.subscription.created':
    case 'customer.subscription.updated':
      await db.query(
        `UPDATE tenants SET plan = $1, subscription_status = $2 WHERE id = $3`,
        [event.data.object.items.data[0].price.lookup_key,
         event.data.object.status, tenantId]
      );
      break;
    case 'customer.subscription.deleted':
      await db.query(
        `UPDATE tenants SET subscription_status = 'canceled' WHERE id = $1`,
        [tenantId]
      );
      break;
  }

  res.json({ received: true });
});

The Mistakes I Made in My First SaaS

I want to be honest about the things I got wrong so you don't repeat them:

  • I added tenantId as an afterthought. I built a working single-tenant app and then "multi-tenanted" it by adding a tenant_id column everywhere. I missed 11 tables. Found this out in production. Do not do this.
  • I didn't enforce RLS from day one. I relied on application-level WHERE clauses and trusted myself. This is not a trust issue — it's an architecture issue. Developers forget things. Databases don't.
  • I shared a connection pool without setting tenant context. Our async request handling meant that setting SET app.current_tenant_id on a pooled connection could bleed into the next request that reused that connection. Using SET LOCAL inside a transaction fixes this.
  • I didn't think about tenant offboarding. Deleting a tenant's data when they cancel is a GDPR requirement. If your tables are a mess, this becomes a multi-day project.
  • I underestimated migration complexity. Running ALTER TABLE on a shared table with millions of rows takes locks that affect all tenants simultaneously.

When to Switch Isolation Models

You should consider migrating from shared-DB to schema-per-tenant when:

  • A single enterprise customer has compliance requirements (SOC2, HIPAA) that demand schema-level isolation
  • You have a tenant whose data volume is causing performance issues for everyone else
  • You need to offer data residency (e.g., EU customer's data must stay in the EU)
  • You're doing a security audit and the auditor flags shared-schema as insufficient

Migration is always painful. The cleanest path I've found: build a background migration job that copies a tenant's rows to their new schema, runs a final sync with the old table still serving traffic, then atomically switches the tenant's database connection string and drops the old rows. It's not fun, but it's survivable.

Final Advice

Start with shared DB + RLS. It will handle your first 200 tenants without drama. Build the abstraction layer (middleware + repository pattern) correctly from day one, and your future migration to schema-per-tenant will be a configuration change, not a rewrite.

Prakash Sharma
Written by
Prakash Sharma

Senior Full-Stack Developer & AI Engineer with 7+ years of experience. Product Lead at Bizspice India. I build production SaaS platforms, RAG systems, Shopify stores, and AI integrations. Writing about what actually works in production — not just in demos.