Node.js

Multi-Tenancy

Multi-tenant architecture patterns in Node.js — subdomain-based tenant detection, per-tenant database connections, schema-per-tenant with Knex, and request-scoped tenant context using AsyncLocalStorage.

Subdomain Detection Middleware

Identify the tenant from the request subdomain (e.g. acme.myapp.com) and attach it to req.tenant for downstream handlers.

// middleware/tenantResolver.js
const AppError = require('../lib/AppError');
const Tenant   = require('../models/Tenant');

/**
 * Resolves tenant from subdomain and attaches to req.tenant.
 * Expects requests like: acme.myapp.com, beta.myapp.com
 */
async function tenantResolver(req, res, next) {
    const host      = req.hostname;                    // e.g. acme.myapp.com
    const parts     = host.split('.');
    const subdomain = parts.length >= 3 ? parts[0] : null;

    // Allow requests to the root domain (landing page, auth, etc.)
    if (!subdomain || subdomain === 'www') return next();

    const tenant = await Tenant.findBySlug(subdomain);

    if (!tenant) {
        return next(AppError.notFound(`Tenant "${subdomain}" not found`));
    }

    if (!tenant.isActive) {
        return next(new AppError('Tenant account is suspended', 403, 'TENANT_SUSPENDED'));
    }

    req.tenant = tenant;   // { id, slug, name, dbName, plan, ... }
    next();
}

module.exports = tenantResolver;

// ── Register globally (after body-parser, before routes) ──────
// app.use(tenantResolver);

Per-Tenant Database Connection Switching

Maintain a pool of Knex connections keyed by tenant ID. Create connections lazily and reuse them across requests to avoid connection storms.

npm install knex mysql2

// lib/tenantDb.js
const knex = require('knex');

// Map of tenantId -> knex instance
const connections = new Map();

/**
 * Returns (or creates) a Knex connection for a given tenant.
 * @param {object} tenant - { id, dbName }
 */
function getTenantDb(tenant) {
    if (connections.has(tenant.id)) {
        return connections.get(tenant.id);
    }

    const db = knex({
        client: 'mysql2',
        connection: {
            host:     process.env.DB_HOST,
            user:     process.env.DB_USER,
            password: process.env.DB_PASSWORD,
            database: tenant.dbName,          // e.g. "tenant_acme"
        },
        pool: { min: 1, max: 5 },
    });

    connections.set(tenant.id, db);
    return db;
}

/**
 * Destroy a tenant connection (e.g. on tenant deactivation).
 */
async function destroyTenantDb(tenantId) {
    const db = connections.get(tenantId);
    if (db) {
        await db.destroy();
        connections.delete(tenantId);
    }
}

module.exports = { getTenantDb, destroyTenantDb };

Schema-Per-Tenant with Knex Migrations

Run Knex migrations against a specific tenant's database to provision new tenants or apply schema updates. Useful when each tenant gets an isolated MySQL database.

// scripts/provision-tenant.js
const knex  = require('knex');
const Tenant = require('../models/Tenant');

async function provisionTenant(tenantSlug) {
    // 1. Create tenant record in master DB
    const tenant = await Tenant.create({ slug: tenantSlug, dbName: `tenant_${tenantSlug}` });

    // 2. Create the tenant database
    const root = knex({ client: 'mysql2', connection: {
        host: process.env.DB_HOST,
        user: process.env.DB_USER,
        password: process.env.DB_PASSWORD,
    }});

    await root.raw(`CREATE DATABASE IF NOT EXISTS \`tenant_${tenantSlug}\``);
    await root.destroy();

    // 3. Run migrations on the new tenant DB
    const tenantDb = knex({ client: 'mysql2', connection: {
        host:     process.env.DB_HOST,
        user:     process.env.DB_USER,
        password: process.env.DB_PASSWORD,
        database: `tenant_${tenantSlug}`,
    }});

    await tenantDb.migrate.latest({ directory: './src/migrations/tenant' });
    await tenantDb.seed.run({ directory: './src/seeds/tenant' });
    await tenantDb.destroy();

    console.log(`✅ Tenant "${tenantSlug}" provisioned successfully`);
    return tenant;
}

module.exports = { provisionTenant };

Tenant Context with AsyncLocalStorage

AsyncLocalStorage (Node.js built-in since v12) provides request-scoped storage without passing req everywhere — like a thread-local for async contexts.

// lib/tenantContext.js
const { AsyncLocalStorage } = require('async_hooks');

const tenantStorage = new AsyncLocalStorage();

/**
 * Run a function within a tenant context.
 * All async code spawned inside fn() can read the tenant.
 */
function runWithTenant(tenant, fn) {
    return tenantStorage.run({ tenant }, fn);
}

function getTenant() {
    const store = tenantStorage.getStore();
    if (!store?.tenant) throw new Error('No tenant context – are you inside a request?');
    return store.tenant;
}

function getTenantSafe() {
    return tenantStorage.getStore()?.tenant ?? null;
}

module.exports = { runWithTenant, getTenant, getTenantSafe };

Wiring AsyncLocalStorage into Express

Set up the tenant context in middleware so every downstream service function (including deeply nested calls) can call getTenant() without needing req as a parameter.

// middleware/tenantContext.js
const { runWithTenant } = require('../lib/tenantContext');

function tenantContextMiddleware(req, res, next) {
    if (!req.tenant) return next();

    // Wrap rest of request pipeline in tenant context
    runWithTenant(req.tenant, () => next());
}

module.exports = tenantContextMiddleware;

// ── Usage in any service (no req needed!) ─────────────────────
// services/productService.js
const { getTenant }    = require('../lib/tenantContext');
const { getTenantDb }  = require('../lib/tenantDb');

async function listProducts() {
    const tenant = getTenant();                    // reads from AsyncLocalStorage
    const db     = getTenantDb(tenant);
    return db('products').select('*').orderBy('name');
}

// ── app.js registration order ─────────────────────────────────
// app.use(tenantResolver);          // 1. detect tenant from subdomain
// app.use(tenantContextMiddleware); // 2. set AsyncLocalStorage
// app.use('/api', apiRouter);       // 3. routes can call getTenant() anywhere