When you’re building multi-tenant Express APIs, the most surprising thing is how much complexity you don’t need to add to your application code if you leverage database-level isolation.

Let’s see this in action. Imagine a simple Express app that needs to serve data for different tenants. Instead of adding if (tenantId === 'tenantA') { ... } else if (tenantId === 'tenantB') { ... } blocks throughout your code, we’ll aim for a model where the database handles tenant separation entirely.

Here’s a basic Express setup:

const express = require('express');
const { Pool } = require('pg'); // Or your preferred DB driver
const app = express();
const port = 3000;

app.use(express.json());

// This pool will be configured to connect to a specific tenant's schema later
let dbPool;

// Middleware to determine tenant and set the correct schema
app.use((req, res, next) => {
    const tenantId = req.headers['x-tenant-id']; // Tenant ID from header
    if (!tenantId) {
        return res.status(400).send('x-tenant-id header is required');
    }

    // Dynamically set the search_path for the current connection
    // This is the core of schema-per-tenant isolation
    dbPool.connect((err, client, done) => {
        if (err) {
            console.error('Error acquiring client', err.stack);
            return res.status(500).send('Database connection error');
        }
        client.query(`SET search_path TO ${tenantId}, public;`, (err, result) => {
            done(); // Release the client back to the pool
            if (err) {
                console.error('Error setting search_path', err.stack);
                return res.status(500).send('Error configuring database connection for tenant');
            }
            // Attach the client to the request for subsequent database operations
            req.dbClient = client;
            next();
        });
    });
});

// Example API endpoint
app.get('/users', async (req, res) => {
    try {
        // Because search_path is set, this query will only hit the 'users' table
        // in the current tenant's schema.
        const { rows } = await req.dbClient.query('SELECT * FROM users');
        res.json(rows);
    } catch (err) {
        console.error('Error fetching users', err.stack);
        res.status(500).send('Error fetching users');
    } finally {
        // Ensure the client is released back to the pool after the request
        req.dbClient.release();
    }
});

// ... other routes ...

app.listen(port, () => {
    console.log(`App listening on port ${port}`);
});

The mental model here is that each tenant gets its own dedicated PostgreSQL schema. So, if you have tenantA and tenantB, you’ll have schemas named tenantA and tenantB in your database. Crucially, you’ll also have a public schema for shared resources and a tenant_management schema (or similar) to store information about your tenants themselves.

When a request comes in, say for tenantA, we extract tenantA from the x-tenant-id header. We then tell PostgreSQL, using SET search_path TO tenantA, public;, to look for tables first in the tenantA schema, and if not found, then in the public schema. This means a query like SELECT * FROM users automatically targets tenantA.users without your application code needing to know or care about tenantA specifically.

Your database setup would look something like this (using psql commands):

  1. Create Tenants and Schemas:

    -- Connect to your database
    \c your_database_name
    
    -- Create schema for tenantA
    CREATE SCHEMA tenantA;
    -- Grant usage on the schema
    GRANT USAGE ON SCHEMA tenantA TO your_app_user;
    
    -- Create schema for tenantB
    CREATE SCHEMA tenantB;
    GRANT USAGE ON SCHEMA tenantB TO your_app_user;
    
    -- Create shared tables in public schema
    CREATE TABLE public.roles (
        id SERIAL PRIMARY KEY,
        name VARCHAR(50) UNIQUE NOT NULL
    );
    INSERT INTO public.roles (name) VALUES ('admin'), ('user');
    
    -- Create tenant-specific tables
    CREATE TABLE tenantA.users (
        id SERIAL PRIMARY KEY,
        username VARCHAR(50) UNIQUE NOT NULL,
        email VARCHAR(100) UNIQUE NOT NULL,
        role_id INT REFERENCES public.roles(id)
    );
    INSERT INTO tenantA.users (username, email, role_id) VALUES ('alice_a', 'alice_a@example.com', 1);
    
    CREATE TABLE tenantB.users (
        id SERIAL PRIMARY KEY,
        username VARCHAR(50) UNIQUE NOT NULL,
        email VARCHAR(100) UNIQUE NOT NULL,
        role_id INT REFERENCES public.roles(id)
    );
    INSERT INTO tenantB.users (username, email, role_id) VALUES ('bob_b', 'bob_b@example.com', 2);
    
  2. Initialize your dbPool in your application:

    const dbPool = new Pool({
        user: 'your_app_user',
        host: 'localhost',
        database: 'your_database_name',
        password: 'your_password',
        port: 5432,
    });
    

    Notice that the Pool configuration is generic. It doesn’t know about specific tenants. The search_path setting is what makes it tenant-aware per connection.

The core lever you control is the search_path configuration for each database connection. When you acquire a client from the pool, you execute SET search_path TO <tenant_schema>, public;. This tells PostgreSQL to resolve unqualified table names (like users) by first looking in <tenant_schema> and then in public. This dramatically simplifies your API routes because they don’t need tenant-specific logic.

A crucial detail often overlooked is how to manage the lifecycle of these database clients. Each request needs to acquire a client, set its search_path, perform its database operations, and then release the client back to the pool. If you don’t release it, you’ll exhaust your connection pool, and subsequent requests will fail. The finally block in the Express route handler is essential for this. Also, the done() callback provided by dbPool.connect in the middleware is vital for releasing the client before it’s handed off to the route handler if there was an error setting the search_path.

The next challenge you’ll face is efficiently managing tenant creation and schema provisioning.

Want structured learning?

Take the full Express course →