ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeBuilding a Multi-Tenant SaaS App with Node.js: Architecture, Isolation, and the Mistakes to Avoid

Building a Multi-Tenant SaaS App with Node.js: Architecture, Isolation, and the Mistakes to Avoid

The three isolation models, Postgres Row Level Security, and the architecture decisions you cannot retrofit later

ZyVOP
ZyVOP
Senior Developer
May 26, 2026
7 min read
Building a Multi-Tenant SaaS App with Node.js: Architecture, Isolation, and the Mistakes to Avoid
#SaaS architecture 2026#multitenancy PostgreSQL#row-level security Postgres#tenant-aware authentication#shared database multitenancy#multi-tenant SaaS Node.js#tenant isolation Node.js

Multitenancy is one of those architectural decisions that is nearly impossible to retrofit. If you build a single-tenant app and decide later to make it multi-tenant, you are essentially rebuilding the data model from scratch. If you build multi-tenant from day one, you get hardware utilization improvements of over 60% compared to single-tenancy — and a product that can serve thousands of customers without provisioning a separate stack for each one.

This guide covers the three isolation models, how to choose between them, and the full implementation of the most common pattern for early-stage SaaS: shared database with row-level tenant isolation.


The Three Isolation Models

Silo (separate database per tenant) — Each customer gets their own database. Maximum isolation, easiest compliance (GDPR, HIPAA), simplest queries (no tenant filtering needed). Expensive to operate at scale — provisioning, migrations, and monitoring multiply by the number of tenants.

Pool (shared database, separate schemas) — Each tenant gets their own Postgres schema (tenant_a.users, tenant_b.users). Good isolation, still manageable, migrations are slightly harder. Postgres supports this well with search_path.

Bridge (shared database, shared tables with tenant ID) — All tenants share tables, every row has a tenant_id column. Cheapest to operate, hardest to get right. Requires strict query discipline and row-level security to prevent data leaks between tenants. This is what most early-stage SaaS apps should use.

This guide implements the Bridge model — the right choice until you have compliance requirements or enterprise customers demanding dedicated infrastructure.


The Database Schema

Every table that holds tenant data needs a tenant_id foreign key. No exceptions.

-- The tenants table is the root of everything
CREATE TABLE tenants (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  slug         TEXT NOT NULL UNIQUE,   -- Used in URLs: app.com/t/acme-corp
  name         TEXT NOT NULL,
  plan         TEXT NOT NULL DEFAULT 'free',
  is_active    BOOLEAN NOT NULL DEFAULT true,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Users belong to tenants
CREATE TABLE users (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  email        TEXT NOT NULL,
  full_name    TEXT NOT NULL,
  role         TEXT NOT NULL DEFAULT 'member',  -- 'owner', 'admin', 'member'
  password_hash TEXT NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  UNIQUE(tenant_id, email)   -- Email unique per tenant, not globally
);

CREATE INDEX idx_users_tenant_id ON users(tenant_id);

-- Every business table follows the same pattern
CREATE TABLE projects (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id    UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name         TEXT NOT NULL,
  created_by   UUID NOT NULL REFERENCES users(id),
  created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_projects_tenant_id ON projects(tenant_id);

Row-Level Security: The Safety Net

Even with disciplined application code, a bug could accidentally expose one tenant's data to another. Postgres Row Level Security (RLS) is a database-level guarantee that prevents this, regardless of what your application does.

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

-- Create a policy: only rows matching the current tenant context are visible
CREATE POLICY tenant_isolation ON users
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

CREATE POLICY tenant_isolation ON projects
  USING (tenant_id = current_setting('app.current_tenant_id')::UUID);

-- Your app user needs to bypass RLS for admin operations
-- Create a separate superuser role for migrations only
-- The app user should NOT have BYPASSRLS

Before every query, your app sets the tenant context:

// src/lib/db.ts
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function withTenantContext<T>(
  tenantId: string,
  fn: (client: PoolClient) => Promise<T>
): Promise<T> {
  const client = await pool.connect();
  try {
    // Set the tenant context for RLS
    await client.query(
      `SELECT set_config('app.current_tenant_id', $1, true)`,
      [tenantId]
    );
    return await fn(client);
  } finally {
    client.release();
  }
}

With RLS enabled, even if a developer forgets to add WHERE tenant_id = ? to a query, Postgres enforces it at the database level. It is the difference between "we trust our developers to never make a mistake" and "we have a structural guarantee."


Tenant Resolution Middleware

Every request needs to resolve which tenant it belongs to. There are three common strategies:

Subdomain: acme.yourapp.com → tenant slug is acmePath prefix: yourapp.com/t/acme/dashboardJWT claim: tenant ID embedded in the auth token

Subdomain is the cleanest UX for B2B SaaS:

// src/middleware/tenantResolver.ts
import { db } from '../lib/db';

export async function resolveTenant(req, res, next) {
  // Extract tenant from subdomain: acme.yourapp.com → 'acme'
  const host = req.hostname;
  const appDomain = process.env.APP_DOMAIN; // 'yourapp.com'

  let tenantSlug: string | null = null;

  if (host.endsWith(`.${appDomain}`)) {
    tenantSlug = host.replace(`.${appDomain}`, '');
  }

  // Fallback: tenant slug in path /t/:slug/...
  if (!tenantSlug && req.params.tenantSlug) {
    tenantSlug = req.params.tenantSlug;
  }

  if (!tenantSlug || tenantSlug === 'www' || tenantSlug === 'api') {
    return next(); // Public routes — no tenant needed
  }

  // Cache tenant lookups in Redis to avoid a DB hit on every request
  const cacheKey = `tenant:slug:${tenantSlug}`;
  let tenant = await redis.get(cacheKey).then(v => v ? JSON.parse(v) : null);

  if (!tenant) {
    const result = await db.query(
      'SELECT id, slug, name, plan, is_active FROM tenants WHERE slug = $1',
      [tenantSlug]
    );
    tenant = result.rows[0] || null;

    if (tenant) {
      // Cache for 5 minutes
      await redis.setex(cacheKey, 300, JSON.stringify(tenant));
    }
  }

  if (!tenant) {
    return res.status(404).json({ error: 'Workspace not found' });
  }

  if (!tenant.is_active) {
    return res.status(403).json({ error: 'Workspace suspended' });
  }

  req.tenant = tenant;
  next();
}

Tenant-Aware Authentication

Users belong to tenants. A user from tenant A should not be able to access tenant B, even with a valid JWT.

// src/middleware/authenticate.ts
export async function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token' });

  let payload;
  try {
    payload = verifyAccessToken(token);
  } catch {
    return res.status(401).json({ error: 'Invalid token' });
  }

  // Critical: verify the token's tenant matches the request's tenant
  if (req.tenant && payload.tenantId !== req.tenant.id) {
    return res.status(403).json({ error: 'Token not valid for this workspace' });
  }

  req.user = { id: payload.sub, role: payload.role, tenantId: payload.tenantId };
  next();
}

When generating tokens, embed the tenant ID:

export function generateAccessToken(user: User, tenant: Tenant) {
  return jwt.sign(
    {
      sub:      user.id,
      tenantId: tenant.id,
      role:     user.role,
      type:     'access',
    },
    PRIVATE_KEY,
    { algorithm: 'RS256', expiresIn: '15m' }
  );
}

Tenant-Scoped Queries

With RLS active, every query automatically filters by the current tenant. But you still need to set the context before querying:

// src/routes/projects.ts
router.get('/', authenticate, async (req, res) => {
  const projects = await withTenantContext(req.tenant.id, async (client) => {
    // RLS automatically adds WHERE tenant_id = req.tenant.id
    // You don't need to add it manually — but it's good practice to anyway
    const result = await client.query(
      'SELECT * FROM projects ORDER BY created_at DESC'
    );
    return result.rows;
  });

  res.json(projects);
});

For your own sanity, always add WHERE tenant_id = $1 explicitly in your queries even though RLS enforces it. It makes the tenant scoping visible in code reviews and protects you if RLS is ever accidentally disabled.


Plan-Based Feature Flags

Multi-tenant SaaS usually means multiple plans. Keep plan enforcement in one place:

// src/lib/planLimits.ts
const PLAN_LIMITS = {
  free: {
    maxProjects:    3,
    maxMembers:     5,
    fileStorageMB:  100,
    apiCallsPerDay: 1000,
  },
  pro: {
    maxProjects:    50,
    maxMembers:     25,
    fileStorageMB:  10000,
    apiCallsPerDay: 50000,
  },
  enterprise: {
    maxProjects:    Infinity,
    maxMembers:     Infinity,
    fileStorageMB:  Infinity,
    apiCallsPerDay: Infinity,
  },
} as const;

export async function checkPlanLimit(
  tenantId: string,
  plan: string,
  resource: keyof typeof PLAN_LIMITS.free
) {
  const limit = PLAN_LIMITS[plan]?.[resource] ?? 0;
  if (limit === Infinity) return; // Enterprise — no limits

  const current = await getCurrentUsage(tenantId, resource);
  if (current >= limit) {
    throw new PlanLimitError(
      `${resource} limit reached for ${plan} plan (${limit}). Upgrade to continue.`
    );
  }
}
// In your route handler
router.post('/projects', authenticate, async (req, res) => {
  try {
    await checkPlanLimit(req.tenant.id, req.tenant.plan, 'maxProjects');
  } catch (err) {
    if (err instanceof PlanLimitError) {
      return res.status(403).json({ error: err.message, upgradeRequired: true });
    }
    throw err;
  }

  const project = await createProject(req.tenant.id, req.user.id, req.body);
  res.status(201).json(project);
});

Tenant Provisioning

When a new customer signs up, you create the tenant and the first user (the owner) atomically:

async function provisionTenant(data: {
  tenantName: string;
  ownerEmail: string;
  ownerName:  string;
  password:   string;
}) {
  return await db.transaction(async (tx) => {
    // Create tenant
    const slug = slugify(data.tenantName) + '-' + randomBytes(3).toString('hex');
    const [tenant] = await tx
      .insert(tenants)
      .values({ name: data.tenantName, slug })
      .returning();

    // Create owner
    const passwordHash = await bcrypt.hash(data.password, 12);
    const [owner] = await tx
      .insert(users)
      .values({
        tenantId:     tenant.id,
        email:        data.ownerEmail,
        fullName:     data.ownerName,
        passwordHash,
        role:         'owner',
      })
      .returning();

    // Queue welcome email
    await emailQueue.add('welcome', {
      userId:     owner.id,
      tenantId:   tenant.id,
      tenantSlug: tenant.slug,
    });

    return { tenant, owner };
  });
}

The Mistakes That Cost the Most

Forgetting tenant_id on a table. One table without tenant scoping becomes a data leak waiting to happen. Audit every table at schema review time.

Not enabling RLS. Application-level tenant filtering is necessary but not sufficient. One missing WHERE clause in one query leaks data. RLS is the structural guarantee.

Caching data without tenant scope. cache:users:all across your entire app is a cross-tenant data leak. Every cache key must include the tenant ID: cache:${tenantId}:users.

Global rate limits instead of per-tenant limits. One tenant hammering your API should not affect another tenant's experience. Rate limit by tenantId, not just by IP.

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1