Multi-Tenant Systems Explained + Example in Node.js

A practical guide to multi-tenancy architecture: understand the three isolation models (database-per-tenant, schema-per-tenant, shared schema), then build a fully working multi-tenant Express + PostgreSQL app from scratch.

Hero image for Multi-Tenant Systems Explained + Example in Node.js

Multi Tenant Systems Explained + Example in NodeJs

One Line: Multi-tenancy is a software architecture where a single application serves multiple tenants while keeping each tenant’s data completely isolated.

Introduction

What is Multi-Tenancy?

Multi-tenancy is a software architecture in which a single application instance serves multiple tenants (customers or organizations). While tenants share the same application, their data remains completely isolated and invisible to others. This model enables efficient resource utilization, simpler maintenance, and reduced costs, since updates and improvements can be applied once and instantly benefit all tenants.

A Good Example is Shopify, a multi-tenant e-commerce platform that allows multiple businesses to create and manage their online stores using a single software system.

Why Multi-Tenancy?

  • Cost Efficiency: Shared resources lead to lower operational costs.
  • Scalability: Easier to scale as the number of tenants grows.
  • Simplified Maintenance: Updates and bug fixes are applied once for all tenants at the same time.
  • Customization: Tenants can often customize their experience without affecting others, such as branding, features sets etc.
  • Resource Optimization: Better utilization of server resources as resources can be dynamically allocated based on demand.

Types of Multi-Tenancy

  1. Database-per-Tenant: Each tenant has its own dedicated database.
  • Pros: Highest level of isolation and security; simplifies compliance requirements.
  • Cons: Complex and costly to manage at scale as the number of tenants grows.
  • Best for: SaaS platforms requiring strong security, strict compliance, and data isolation (e.g., healthcare, finance).
  1. Schema-per-Tenant: All tenants share a single database, but each tenant gets its own schema.
  • Pros: Good balance of isolation and resource efficiency; simpler to scale than database-per-tenant.
  • Cons: Still requires schema management; migrations can become tricky with many schemas.
  • Best for: Applications needing moderate isolation without the overhead of managing multiple databases.
  1. Shared Database, Shared Schema: All tenants share the same database and schema, with data separated using a tenant_id column. Row-Level Security (RLS) is often applied here.
  • Pros: Most resource-efficient; simplest to provision and scale for many small tenants.
  • Cons: Lowest isolation; requires very strict safeguards in queries and code to prevent data leaks.
  • Best for: SaaS apps serving a large number of small tenants with similar data structures and lighter security requirements.

Challenges in Multi Tenant Systems

  • Data Isolation: Ensuring that one tenant’s data is completely isolated from others.
  • Performance: Balancing resource allocation to ensure fair performance across tenants.
  • Customization: Allowing tenants to customize their experience without affecting others.
  • Security: Implementing robust security measures to protect tenant data.
  • Scalability: Designing the system to handle a growing number of tenants efficiently.
  • Maintenance: Managing updates and changes without disrupting tenant operations.
  • Compliance: Meeting regulatory requirements for data protection and privacy.

Implementation in NodeJS

For this tutorial, we’ll use Express.js and PostgreSQL to build a simple multi-tenant to-do application. Each tenant has completely isolated data while sharing the same application infrastructure.

Project Setup

mkdir multi-tenant-todo && cd multi-tenant-todo
npm init -y
npm install express pg dotenv

Database Schema

We use the shared database, shared schema model with a tenant_id column for isolation:

CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  name VARCHAR(255) NOT NULL UNIQUE,
  slug VARCHAR(100) NOT NULL UNIQUE,
  created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE todos (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  title VARCHAR(500) NOT NULL,
  completed BOOLEAN DEFAULT false,
  created_at TIMESTAMPTZ DEFAULT now()
);

-- Index for fast tenant filtering (critical for performance)
CREATE INDEX idx_todos_tenant_id ON todos(tenant_id);

Tenant Resolution Middleware

The key is identifying which tenant is making each request. We use the subdomain (acme.myapp.com → slug acme):

// middleware/tenant.js
import pg from "pg";
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });

const tenantCache = new Map();

export async function resolveTenant(req, res, next) {
  // Identify tenant from subdomain
  const slug = req.hostname.split(".")[0];

  if (tenantCache.has(slug)) {
    req.tenant = tenantCache.get(slug);
    return next();
  }

  const { rows } = await db.query(
    "SELECT id, name, slug FROM tenants WHERE slug = $1",
    [slug],
  );

  if (rows.length === 0) {
    return res.status(404).json({ error: `Tenant '${slug}' not found` });
  }

  tenantCache.set(slug, rows[0]);
  req.tenant = rows[0];
  next();
}

Tenant-Scoped Database Helper

Every query must be scoped to the current tenant. This helper enforces that pattern:

// db/tenantDb.js
import pg from "pg";
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

export function createTenantDb(tenantId) {
  return {
    async findAll(table) {
      const { rows } = await pool.query(
        `SELECT * FROM ${table} WHERE tenant_id = $1 ORDER BY created_at DESC`,
        [tenantId],
      );
      return rows;
    },

    async insert(table, data) {
      const record = { ...data, tenant_id: tenantId };
      const keys = Object.keys(record).join(", ");
      const placeholders = Object.keys(record)
        .map((_, i) => `$${i + 1}`)
        .join(", ");
      const { rows } = await pool.query(
        `INSERT INTO ${table} (${keys}) VALUES (${placeholders}) RETURNING *`,
        Object.values(record),
      );
      return rows[0];
    },

    async delete(table, id) {
      const { rowCount } = await pool.query(
        `DELETE FROM ${table} WHERE id = $1 AND tenant_id = $2`,
        [id, tenantId],
      );
      return rowCount > 0;
    },
  };
}

Express Routes

// routes/todos.js
import express from "express";
import { createTenantDb } from "../db/tenantDb.js";

const router = express.Router();

// GET /todos — all todos for this tenant only
router.get("/", async (req, res) => {
  const db = createTenantDb(req.tenant.id);
  const todos = await db.findAll("todos");
  res.json(todos);
});

// POST /todos — create a todo scoped to this tenant
router.post("/", async (req, res) => {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: "Title is required" });
  const db = createTenantDb(req.tenant.id);
  const todo = await db.insert("todos", { title, completed: false });
  res.status(201).json(todo);
});

// DELETE /todos/:id — can only delete within this tenant
router.delete("/:id", async (req, res) => {
  const db = createTenantDb(req.tenant.id);
  const deleted = await db.delete("todos", req.params.id);
  if (!deleted) return res.status(404).json({ error: "Todo not found" });
  res.status(204).send();
});

export default router;

App Entry Point

// app.js
import express from "express";
import "dotenv/config";
import { resolveTenant } from "./middleware/tenant.js";
import todosRouter from "./routes/todos.js";

const app = express();
app.use(express.json());
app.use(resolveTenant); // ← tenant resolved before all routes
app.use("/todos", todosRouter);

app.listen(3000, () => console.log("Server running on port 3000"));

Testing Data Isolation

# Acme creates a todo
curl -X POST http://acme.localhost:3000/todos \
  -H "Content-Type: application/json" \
  -d '{"title": "Acme first task"}'

# Globex lists todos — returns empty (cannot see Acme's data)
curl http://globex.localhost:3000/todos
# Returns: []  ← isolation confirmed

Key Takeaways

ConcernSolution
Tenant identificationSubdomain / header / JWT claim
Data isolationtenant_id on every table + helper functions
PerformanceIndex tenant_id on all tables
Cross-tenant safetyNever query without tenant_id filter
Stronger isolationUpgrade to RLS (see our RLS post) or schema-per-tenant

Multi-tenancy is conceptually simple but requires consistent discipline in execution. By centralizing both tenant resolution and query scoping, you make it structurally difficult to accidentally return another tenant’s data — which is exactly the kind of safety SaaS applications need.


💬 Want to learn, build, and grow with a community of developers? Join the King Technologies Discord — where code meets community!