DEV Community

Cover image for Building a Multi-Tenant SaaS App with React and Node.js in 2026
Waqar Habib
Waqar Habib Subscriber

Posted on

Building a Multi-Tenant SaaS App with React and Node.js in 2026

Multi-tenancy is one of those SaaS architecture decisions that looks simple on day one and becomes your biggest constraint by year two.

The decision you make early: How to isolate tenant data, how to scope authentication, how to handle tenant-specific configuration, shapes everything that comes after. Getting it wrong doesn't usually break your app. It just makes every subsequent feature twice as hard to build.

Here's what I've learned building multi-tenant SaaS products for the US market, using React on the frontend and Node.js on the backend.


The Three Tenancy Models (and When to Use Each)

Before writing a line of code, you need to pick your isolation model. There are three:

1. Shared Database, Shared Schema

All tenants live in the same tables, separated by a tenant_id column.

-- Every table has this column
CREATE TABLE projects (
  id UUID PRIMARY KEY,
  tenant_id UUID NOT NULL REFERENCES tenants(id),
  name VARCHAR(255),
  created_at TIMESTAMP DEFAULT NOW()
);

-- Every query filters by it
SELECT * FROM projects WHERE tenant_id = $1 AND id = $2;
Enter fullscreen mode Exit fullscreen mode

Best for: Early-stage SaaS, cost-sensitive products, B2C or SMB markets.
Risk: A missing WHERE tenant_id = ... clause leaks one tenant's data to another. This happens more than people admit.

2. Shared Database, Separate Schemas

Each tenant gets their own PostgreSQL schema (tenant_a.projects, tenant_b.projects), but they share the same database instance.

// Set the search path at the start of each request
await pool.query(`SET search_path TO tenant_${tenantId}`);
const result = await pool.query('SELECT * FROM projects WHERE id = $1', [projectId]);
Enter fullscreen mode Exit fullscreen mode

Best for: Mid-market SaaS where tenants want data separation but you can't justify per-tenant databases yet.
Risk: Schema migrations become expensive. You're running them N times across all tenant schemas.

3. Separate Databases Per Tenant

Each tenant gets their own database instance. Maximum isolation, maximum cost.

Best for: Enterprise SaaS with compliance requirements (HIPAA, SOC 2, FINRA). US healthcare and fintech clients often require this contractually.
Risk: Operational complexity. Connection pooling, migrations, monitoring, all multiplied by tenant count.

For most US SaaS startups I work with, shared database with shared schema is the right starting point, with a clean abstraction layer that makes switching models possible later.


The Tenant Context Pattern in Node.js

The most important architectural decision in a shared-schema multi-tenant app is how you propagate tenant context through your request lifecycle. The naive approach is passing tenantId as a parameter everywhere:

// Naive — tenantId passed through every function
async function getProjects(tenantId, userId) {
  return db.query('SELECT * FROM projects WHERE tenant_id = $1', [tenantId]);
}
Enter fullscreen mode Exit fullscreen mode

This works but pollutes every function signature. A better approach is using AsyncLocalStorage to set tenant context once per request and access it anywhere:

const { AsyncLocalStorage } = require('async_hooks');
const tenantContext = new AsyncLocalStorage();

// Middleware: set context once
app.use(async (req, res, next) => {
  const tenant = await resolveTenant(req); // from subdomain, JWT, or API key
  tenantContext.run({ tenantId: tenant.id }, next);
});

// Service layer: access context without passing it
function getDb() {
  const { tenantId } = tenantContext.getStore();
  return {
    query: (sql, params) => pool.query(
      sql.replace('FROM ', `FROM /* tenant:${tenantId} */ `),
      params
    )
  };
}

async function getProjects() {
  // No tenantId parameter needed — it's in context
  return getDb().query('SELECT * FROM projects WHERE tenant_id = $1',
    [tenantContext.getStore().tenantId]
  );
}
Enter fullscreen mode Exit fullscreen mode

This is the same pattern that ORMs like Prisma use internally for middleware context, and it makes your service layer much cleaner.


Tenant Resolution on the Frontend (React)

On the React side, multi-tenancy usually means one of three URL patterns:

  • Subdomain: acme.yourapp.com, globex.yourapp.com
  • Path prefix: yourapp.com/acme/dashboard
  • Custom domain: app.acme.com → resolves to tenant "acme"

Subdomain is the most common for US SaaS products. Here's how to resolve it in React at the app root:

// hooks/useTenant.js
export function useTenant() {
  const hostname = window.location.hostname;
  // Extract subdomain: "acme.yourapp.com" → "acme"
  const parts = hostname.split('.');
  const subdomain = parts.length > 2 ? parts[0] : null;
  return subdomain;
}

// App.jsx
function App() {
  const tenantSlug = useTenant();

  const { data: tenant, isLoading } = useQuery({
    queryKey: ['tenant', tenantSlug],
    queryFn: () => api.get(`/tenants/resolve?slug=${tenantSlug}`),
    enabled: !!tenantSlug,
  });

  if (isLoading) return <LoadingScreen />;
  if (!tenant) return <TenantNotFound />;

  return (
    <TenantContext.Provider value={tenant}>
      <Router />
    </TenantContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Every component downstream can call useTenant() and get the resolved tenant object — branding, feature flags, plan limits — without prop drilling.


Tenant-Scoped Feature Flags

One of the most powerful patterns in multi-tenant SaaS is per-tenant feature flags. Different tenants on different plans get different features — and you want to control this without deploying code.

// Backend: tenant feature resolution
async function getTenantFeatures(tenantId) {
  const tenant = await db.query(
    'SELECT plan, feature_overrides FROM tenants WHERE id = $1',
    [tenantId]
  );

  const planFeatures = {
    starter: ['basic_analytics', 'csv_export'],
    growth:  ['basic_analytics', 'csv_export', 'api_access', 'webhooks'],
    enterprise: ['*'], // all features
  };

  const base = planFeatures[tenant.plan] || [];
  const overrides = tenant.feature_overrides || {};

  return { ...base, ...overrides };
}

// Frontend: feature gate component
function Feature({ name, children, fallback = null }) {
  const { features } = useTenant();
  if (features.includes('*') || features.includes(name)) {
    return children;
  }
  return fallback;
}

// Usage
<Feature name="webhooks" fallback={<UpgradePrompt />}>
  <WebhookSettings />
</Feature>
Enter fullscreen mode Exit fullscreen mode

This pattern lets you roll out features gradually, A/B test across tenant cohorts, and handle enterprise one-offs without branching your codebase.


The Migration Problem

Every multi-tenant app eventually faces the question: how do you run database migrations when your schema is shared across all tenants?

For shared-schema apps, Knex or Prisma migrations work normally. One migration touches all tenant data. The risk is a bad migration affecting all tenants simultaneously.

The pattern I use with US enterprise clients is blue-green migrations: deploy the new schema alongside the old one, backfill data, then switch the application over, then clean up the old schema in a separate deployment. Never do destructive schema changes in a single deployment.

// Step 1: Add new column (non-breaking)
await knex.schema.table('projects', (t) => {
  t.string('status_v2').defaultTo('active'); // new column, old code still works
});

// Step 2: Backfill (run as a background job, not in migration)
await knex('projects').update({ status_v2: knex.ref('status') });

// Step 3: Switch app to use status_v2 — deploy separately

// Step 4: Drop old column — deploy separately, weeks later
await knex.schema.table('projects', (t) => {
  t.dropColumn('status');
});
Enter fullscreen mode Exit fullscreen mode

Checklist Before You Go Live

Before launching a multi-tenant SaaS to US customers, run through these:

  • [ ] Every database query is scoped by tenant_id, no exceptions
  • [ ] Row-level security (RLS) enabled in Postgres as a safety net
  • [ ] JWT tokens include tenant_id claim, verified on every request
  • [ ] Tenant context cannot be overridden by user-supplied input
  • [ ] File storage is prefixed by tenant: s3://bucket/tenant-id/...
  • [ ] Rate limiting is applied per tenant, not globally
  • [ ] Audit logs record tenant_id on every write operation
  • [ ] Your billing system tracks usage per tenant from day one

Building a multi-tenant SaaS product for the US market has specific requirements around compliance, data isolation, and scalability that differ from European or enterprise markets. If you're at the architecture stage and want experienced input, I specialize in SaaS development for US startups and can help you avoid the decisions that become expensive later. See my work at waqarhabib.com/services/saas-development.


Originally published at waqarhabib.com

Top comments (0)