ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

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

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
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

#multi-tenant SaaS Node.js#multitenancy PostgreSQL#row-level security Postgres#tenant isolation Node.js#SaaS architecture 2026#shared database multitenancy#tenant-aware authentication
Z
ZyVOP

Senior Developer

May 26, 2026
8 min read
4 views
Building a Multi-Tenant SaaS App with Node.js: Architecture, Isolation, and the Mistakes to Avoid

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.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design