DEV Community

1xApi
1xApi

Posted on • Originally published at 1xapi.com

How to Build a Multi-Tenant API in Node.js with PostgreSQL RLS (2026 Guide)

Building a multi-tenant API is one of the most critical architectural decisions you'll make when shipping a SaaS product. Get it right and you can scale to thousands of customers on shared infrastructure. Get it wrong and you're looking at data leaks, compliance nightmares, and re-architecting under pressure.

This guide walks through a complete, production-ready multi-tenant API in Node.js — covering tenant identification, database isolation strategies, per-tenant rate limiting, and feature gating. Everything is based on patterns battle-tested in 2026 SaaS stacks.

What Is Multi-Tenancy and Why Does It Matter?

Multi-tenancy means a single instance of your API serves multiple customers (tenants) — each with their own isolated data, config, and usage limits. Instead of spinning up a separate deployment per customer (which is costly and operationally painful), you share infrastructure while keeping each tenant's data strictly separated.

The three mainstream isolation models in 2026 are:

Strategy Isolation Level Cost Complexity
Shared schema (single DB, tenant_id column) Logical Low Low
Schema-per-tenant (separate Postgres schemas) Medium Medium Medium
Database-per-tenant Physical High High

For most SaaS APIs serving SMB customers, shared schema + PostgreSQL Row-Level Security (RLS) is the sweet spot. It's what Notion, Linear, and many modern SaaS tools use internally.

Project Setup

npm init -y
npm install express pg ioredis zod jsonwebtoken dotenv
npm install -D typescript @types/express @types/node tsx
Enter fullscreen mode Exit fullscreen mode

Project structure:

src/
  middleware/
    tenant.ts       # Tenant identification + context
    rateLimiter.ts  # Per-tenant rate limiting
  db/
    pool.ts         # PostgreSQL connection pool
    rls.ts          # RLS session variable helper
  routes/
    api.ts          # Protected routes
  types.ts          # Tenant types
index.ts
Enter fullscreen mode Exit fullscreen mode

Step 1: Tenant Identification Middleware

The first thing every request needs to do is resolve which tenant it belongs to. In 2026, there are three common strategies — and a production API should support all three:

  1. Subdomain: acme.yourapi.com → tenant slug acme
  2. Header: X-Tenant-ID: tenant_abc123
  3. JWT claim: Decoded from Authorization: Bearer <token>
// src/middleware/tenant.ts
import { Request, Response, NextFunction } from 'express';
import { pool } from '../db/pool';

export interface TenantContext {
  id: string;          // UUID
  slug: string;        // e.g. "acme"
  plan: 'free' | 'pro' | 'enterprise';
  features: string[];  // enabled feature flags
  rateLimit: number;   // requests/minute
}

declare global {
  namespace Express {
    interface Request {
      tenant: TenantContext;
    }
  }
}

async function resolveTenant(identifier: string, type: 'id' | 'slug'): Promise<TenantContext | null> {
  const column = type === 'id' ? 'id' : 'slug';
  const { rows } = await pool.query(
    `SELECT id, slug, plan, features, rate_limit
     FROM tenants
     WHERE ${column} = $1 AND active = true`,
    [identifier]
  );
  return rows[0] ?? null;
}

export function tenantMiddleware() {
  return async (req: Request, res: Response, next: NextFunction) => {
    try {
      let tenant: TenantContext | null = null;

      // Strategy 1: Subdomain (acme.api.yourapp.com)
      const host = req.hostname;
      const subdomain = host.split('.')[0];
      if (subdomain && subdomain !== 'api' && subdomain !== 'www') {
        tenant = await resolveTenant(subdomain, 'slug');
      }

      // Strategy 2: X-Tenant-ID header (API key flows)
      if (!tenant && req.headers['x-tenant-id']) {
        tenant = await resolveTenant(req.headers['x-tenant-id'] as string, 'id');
      }

      // Strategy 3: JWT claim (user-facing flows)
      if (!tenant && req.headers.authorization) {
        const token = req.headers.authorization.replace('Bearer ', '');
        try {
          const jwt = require('jsonwebtoken');
          const decoded = jwt.verify(token, process.env.JWT_SECRET!) as any;
          if (decoded.tenant_id) {
            tenant = await resolveTenant(decoded.tenant_id, 'id');
          }
        } catch {
          // Invalid token — let auth middleware handle it
        }
      }

      if (!tenant) {
        return res.status(400).json({
          error: 'tenant_required',
          message: 'Could not resolve tenant from request'
        });
      }

      req.tenant = tenant;
      next();
    } catch (err) {
      next(err);
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Important: Cache tenant lookups in Redis with a short TTL (30–60s). In a high-traffic API, hitting the DB on every request to resolve the tenant kills performance.

// Cached version with Redis
async function resolveTenantCached(identifier: string, type: 'id' | 'slug'): Promise<TenantContext | null> {
  const cacheKey = `tenant:${type}:${identifier}`;
  const redis = getRedis();

  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  const tenant = await resolveTenant(identifier, type);
  if (tenant) {
    await redis.setex(cacheKey, 60, JSON.stringify(tenant)); // 60s TTL
  }
  return tenant;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: PostgreSQL Row-Level Security (RLS)

RLS is a PostgreSQL feature that enforces data isolation at the database engine level — meaning even if your application code has a bug and forgets to filter by tenant_id, the database will still block cross-tenant access.

Set up the schema:

-- Create tenants table
CREATE TABLE tenants (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug TEXT UNIQUE NOT NULL,
  plan TEXT NOT NULL DEFAULT 'free',
  features TEXT[] DEFAULT '{}',
  rate_limit INTEGER DEFAULT 100,
  active BOOLEAN DEFAULT true,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Example: a documents table with tenant isolation
CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  title TEXT NOT NULL,
  content TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Create a restricted DB role for the app
CREATE ROLE api_user LOGIN PASSWORD 'your_secure_password';
GRANT CONNECT ON DATABASE saas_db TO api_user;
GRANT USAGE ON SCHEMA public TO api_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO api_user;

-- Enable RLS on all tenant-scoped tables
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;

-- Create isolation policy
CREATE POLICY tenant_isolation ON documents
  FOR ALL
  TO api_user
  USING (tenant_id = current_setting('app.current_tenant')::uuid)
  WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
Enter fullscreen mode Exit fullscreen mode

Set the session variable before each query:

// src/db/rls.ts
import { Pool, PoolClient } from 'pg';

export async function withTenantContext<T>(
  pool: Pool,
  tenantId: string,
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    // Set the tenant context for RLS
    await client.query(`SET LOCAL app.current_tenant = '${tenantId}'`);
    const result = await fn(client);
    return result;
  } finally {
    client.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Usage in a route:

// src/routes/api.ts
import { Router } from 'express';
import { withTenantContext } from '../db/rls';
import { pool } from '../db/pool';

const router = Router();

router.get('/documents', async (req, res) => {
  const { tenant } = req;

  const documents = await withTenantContext(pool, tenant.id, async (client) => {
    // RLS automatically filters by tenant_id — no WHERE clause needed
    const { rows } = await client.query(
      `SELECT id, title, created_at FROM documents ORDER BY created_at DESC LIMIT 50`
    );
    return rows;
  });

  res.json({ documents, tenant: tenant.slug });
});

router.post('/documents', async (req, res) => {
  const { tenant } = req;
  const { title, content } = req.body;

  const doc = await withTenantContext(pool, tenant.id, async (client) => {
    const { rows } = await client.query(
      `INSERT INTO documents (tenant_id, title, content) VALUES ($1, $2, $3) RETURNING *`,
      [tenant.id, title, content]
    );
    return rows[0];
  });

  res.status(201).json(doc);
});

export default router;
Enter fullscreen mode Exit fullscreen mode

With this setup, a query from tenant A cannot return data from tenant B — even if there's a code bug. The database enforces it.

Step 3: Per-Tenant Rate Limiting

Different plans get different rate limits. Free-tier tenants get 100 req/min, Pro gets 1,000, Enterprise gets 10,000 — all enforced with a Redis token bucket.

// src/middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

export function perTenantRateLimit() {
  return async (req: Request, res: Response, next: NextFunction) => {
    const { tenant } = req;
    const key = `ratelimit:${tenant.id}`;
    const limit = tenant.rateLimit; // e.g. 100 for free, 1000 for pro
    const window = 60; // seconds

    const pipeline = redis.pipeline();
    pipeline.incr(key);
    pipeline.ttl(key);
    const results = await pipeline.exec();

    const count = results![0][1] as number;
    const ttl = results![1][1] as number;

    if (ttl === -1) {
      await redis.expire(key, window);
    }

    const remaining = Math.max(0, limit - count);
    const resetAt = ttl > 0 ? ttl : window;

    // Set standard rate limit headers
    res.setHeader('X-RateLimit-Limit', limit);
    res.setHeader('X-RateLimit-Remaining', remaining);
    res.setHeader('X-RateLimit-Reset', resetAt);
    res.setHeader('X-Tenant-Plan', tenant.plan);

    if (count > limit) {
      return res.status(429).json({
        error: 'rate_limit_exceeded',
        message: `Rate limit of ${limit} req/min exceeded. Upgrade your plan for higher limits.`,
        reset_in_seconds: resetAt
      });
    }

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Feature Gating by Plan

Not all tenants get all features. Feature gating lets you progressively roll out capabilities:

// src/middleware/featureGate.ts
import { Request, Response, NextFunction } from 'express';

export function requireFeature(feature: string) {
  return (req: Request, res: Response, next: NextFunction) => {
    const { tenant } = req;

    if (!tenant.features.includes(feature)) {
      return res.status(403).json({
        error: 'feature_not_available',
        message: `The '${feature}' feature is not available on the ${tenant.plan} plan.`,
        upgrade_url: 'https://1xapi.com/pricing'
      });
    }

    next();
  };
}
Enter fullscreen mode Exit fullscreen mode

Usage — gate specific endpoints by feature flag:

// Only pro/enterprise tenants can access analytics
router.get('/analytics',
  requireFeature('analytics'),
  async (req, res) => {
    // ...
  }
);

// Only enterprise tenants get custom webhooks
router.post('/webhooks',
  requireFeature('custom_webhooks'),
  async (req, res) => {
    // ...
  }
);
Enter fullscreen mode Exit fullscreen mode

Step 5: Wiring It All Together

// index.ts
import express from 'express';
import { tenantMiddleware } from './src/middleware/tenant';
import { perTenantRateLimit } from './src/middleware/rateLimiter';
import apiRouter from './src/routes/api';

const app = express();
app.use(express.json());

// Global tenant resolution
app.use('/api', tenantMiddleware());

// Per-tenant rate limiting (after tenant is resolved)
app.use('/api', perTenantRateLimit());

// Protected API routes
app.use('/api/v1', apiRouter);

// Error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
  console.error({ err, tenant: req.tenant?.slug });
  res.status(500).json({ error: 'internal_server_error' });
});

app.listen(3000, () => console.log('Multi-tenant API running on :3000'));
Enter fullscreen mode Exit fullscreen mode

Testing Tenant Isolation

Always write explicit cross-tenant isolation tests:

// test/isolation.test.ts
import { describe, it, expect, beforeAll } from 'vitest';

describe('Tenant isolation', () => {
  let tenantAToken: string;
  let tenantBToken: string;
  let tenantADocId: string;

  beforeAll(async () => {
    // Create test tenants and get tokens
    tenantAToken = await createTestTenant('tenant-a');
    tenantBToken = await createTestTenant('tenant-b');
  });

  it('Tenant A can create and read their own documents', async () => {
    const res = await fetch('/api/v1/documents', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${tenantAToken}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ title: 'Tenant A doc' })
    });
    expect(res.status).toBe(201);
    const doc = await res.json();
    tenantADocId = doc.id;
  });

  it('Tenant B CANNOT access Tenant A documents', async () => {
    const res = await fetch(`/api/v1/documents`, {
      headers: { 'Authorization': `Bearer ${tenantBToken}` }
    });
    const { documents } = await res.json();
    const leaked = documents.find((d: any) => d.id === tenantADocId);
    expect(leaked).toBeUndefined(); // RLS blocks it at the DB level
  });
});
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

Running a multi-tenant API at scale has a few gotchas:

Connection pooling: With RLS, each query needs SET LOCAL app.current_tenant — this must run on the same connection. Use pgBouncer in transaction mode (not session mode) and always call SET LOCAL inside a transaction block.

Noisy neighbor: A Free-tier tenant hammering your API shouldn't degrade Pro tenants. Per-tenant rate limiting (Step 3) handles request volume, but also consider per-tenant query timeouts:

-- Set per-tenant statement timeout in the session
SET LOCAL statement_timeout = '5s';
Enter fullscreen mode Exit fullscreen mode

Tenant onboarding: When a new tenant signs up, use a queue (BullMQ, Inngest) to run their schema migrations asynchronously — don't block the signup response.

What's Next

This pattern scales well to thousands of tenants. When you hit the limits of shared schema (usually around 10M+ rows per table), migrate high-value tenants to dedicated schemas or databases without changing the API surface — the middleware handles routing transparently.

For teams building APIs that need to be consumed across multiple organizations, explore the 1xAPI marketplace for pre-built API building blocks — authentication, rate limiting, and tenant management primitives you can plug in without starting from scratch.

Top comments (0)