SaaS

How to build a multi-tenant SaaS: architecture, data isolation and the mistakes to avoid

How to build a multi-tenant SaaS: architecture, data isolation and the mistakes to avoid

If you’re building a SaaS product, every customer company (a tenant) signs in to the same app and expects to see only its own data, users and settings. You run one codebase. They each feel like it’s theirs.

How you separate those tenants is one of the few early decisions that’s genuinely painful to change later. We build multi-tenant products and customer portals, and we enforce isolation in the database rather than trusting the application code. This guide explains the reasoning, then walks through a small working API for a fictional project-management SaaS, with the real output of each step.

Three ways to keep tenants apart

ModelIsolationRunning costWhen it fits
Shared tables + tenant_id + row-level securityStrong, enforced by the databaseLowest; one schema change per releaseOur default for most products
Schema per tenantStrongerEvery change rolled out to every schemaTens of tenants, rarely hundreds
Database per tenantStrongestHighest; backups, upgrades and monitoring per tenantA few large customers with strict or data-location requirements

A pattern that works well is starting with shared tables and offering a dedicated database later, as a premium option for the handful of customers who need it. The rest of this guide uses shared tables.

Diagram: web requests, background jobs and the AI assistant all resolve the tenant first, then run inside a transaction that sets app.tenant_id; PostgreSQL applies RLS, defaults and plan-limit triggers
One helper decides the tenant for requests, jobs and AI search alike.

Step 1: Put the rule in the database

The bug that worries us most in multi-tenant apps is a single missing filter. Somebody writes a new report query, forgets the “where tenant_id = …” line, and one customer sees another customer’s orders. In PostgreSQL and Supabase, row-level security fixes this: a policy on each table, applied to every query no matter who wrote it.

sql
-- Tenants, plans and memberships
create table plans (
  id           text primary key,
  max_projects int  not null,
  max_seats    int  not null
);
insert into plans values ('starter', 3, 5), ('pro', 50, 25);

create table tenants (
  id        uuid primary key default gen_random_uuid(),
  subdomain text unique not null check (subdomain ~ '^[a-z0-9-]{3,30}$'),
  name      text not null,
  plan_id   text not null references plans default 'starter'
);

create table users (id uuid primary key default gen_random_uuid(), email text unique not null);

create table memberships (
  tenant_id uuid references tenants on delete cascade,
  user_id   uuid references users on delete cascade,
  role      text not null check (role in ('owner', 'admin', 'member')),
  primary key (tenant_id, user_id)
);

-- Tenant data: every table carries tenant_id, filled in from the session
create function current_tenant() returns uuid language sql stable as $$
  select nullif(current_setting('app.tenant_id', true), '')::uuid
$$;

create table projects (
  id        bigint generated always as identity primary key,
  tenant_id uuid not null references tenants default current_tenant(),
  name      text not null
);

create table documents (
  id        bigint generated always as identity primary key,
  tenant_id uuid not null references tenants default current_tenant(),
  title     text not null,
  body      text not null,
  search    tsvector generated always as (to_tsvector('english', title || ' ' || body)) stored
);
create index on documents using gin (search);
create index on projects (tenant_id);
create index on documents (tenant_id);

-- Row-level security on every tenant table
alter table projects  enable row level security;
alter table documents enable row level security;
create policy tenant_isolation on projects  using (tenant_id = current_tenant()) with check (tenant_id = current_tenant());
create policy tenant_isolation on documents using (tenant_id = current_tenant()) with check (tenant_id = current_tenant());

-- The app connects as this role. It does not own the tables, so RLS applies to it.
create role saas_app login password 'demo-only';
grant select, insert, update, delete on projects, documents to saas_app;
grant select on tenants, plans, memberships, users to saas_app;
grant usage on all sequences in schema public to saas_app;
tenant_id defaults to the current tenant, so application code never sets it.

This version reads the tenant from a transaction setting, app.tenant_id, which suits any PostgreSQL host. On Supabase you can derive it from auth.uid() instead; our multi-tenant finance portal tutorial shows that variant with staff access per client.

Step 2: Create a tenant in one step

Signing up a new customer should be one automated step that creates everything they need: the tenant, the owner’s membership, a starter project and a welcome document. Plan limits live in a trigger, so they hold for every route, import and script.

sql
-- One step creates everything a new tenant needs
create function create_tenant(p_subdomain text, p_name text, p_owner_email text, p_plan text default 'starter')
returns uuid language plpgsql security definer set search_path = public as $$
declare
  t uuid;
  u uuid;
begin
  insert into tenants (subdomain, name, plan_id) values (p_subdomain, p_name, p_plan) returning id into t;
  insert into users (email) values (p_owner_email)
    on conflict (email) do update set email = excluded.email returning id into u;
  insert into memberships values (t, u, 'owner');
  insert into projects (tenant_id, name) values (t, 'Getting started');
  insert into documents (tenant_id, title, body)
    values (t, 'Welcome', 'Your workspace is ready. Invite your team from Settings.');
  return t;
end $$;

-- Plan limits enforced in the database, so no API route can skip them
create function enforce_project_limit() returns trigger language plpgsql security definer set search_path = public as $$
declare
  used    int;
  allowed int;
  plan    text;
begin
  perform pg_advisory_xact_lock(hashtext(new.tenant_id::text));   -- two requests at once can't both squeeze in
  select count(*) into used from projects where tenant_id = new.tenant_id;
  select p.max_projects, p.id into allowed, plan from tenants t join plans p on p.id = t.plan_id where t.id = new.tenant_id;
  if used >= allowed then
    raise exception 'Plan limit reached: % allows % projects', initcap(plan), allowed using errcode = 'P0001';
  end if;
  return new;
end $$;
create trigger project_limit before insert on projects for each row execute function enforce_project_limit();
The advisory lock stops two simultaneous requests from both slipping under the limit.
psql output: two tenants created, acme on starter and globex on pro, each with one project and one document
Two tenants, each with its owner, a starter project and a welcome document.

Step 3: Decide the tenant from the session

The tenant comes from the subdomain, and the signed-in user must be a member of it. The tenant id is then set for the duration of one transaction. We’ve reviewed apps that read the tenant from a URL parameter; with this pattern there’s nothing in the request that could change it.

javascript
import pg from 'pg';

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });   // connects as saas_app

// Run fn inside a transaction that knows its tenant. set_config(..., true) lasts for this
// transaction only, so a pooled connection can't carry one tenant into the next request.
export async function withTenant(tenantId, fn) {
  const client = await pool.connect();
  try {
    await client.query('begin');
    await client.query("select set_config('app.tenant_id', $1, true)", [tenantId]);
    const result = await fn(client);
    await client.query('commit');
    return result;
  } catch (err) {
    await client.query('rollback');
    throw err;
  } finally {
    client.release();
  }
}
javascript
import express from 'express';
import { pool, withTenant } from './db.mjs';

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

// Demo sessions. In production this is your auth provider's verified session.
const SESSIONS = { 'sess-maya': 'maya@acme.test', 'sess-sam': 'sam@globex.test' };

// Tenant comes from the subdomain, and the signed-in user must be a member of it.
// Nothing the browser sends in the body or query string can change it.
async function resolveTenant(req, res, next) {
  const email = SESSIONS[req.get('Authorization')?.replace('Bearer ', '')];
  if (!email) return res.status(401).json({ error: 'Not signed in' });
  const subdomain = req.hostname.split('.')[0];
  const { rows } = await pool.query(
    `select t.id, t.name, m.role from tenants t
       join memberships m on m.tenant_id = t.id
       join users u on u.id = m.user_id
      where t.subdomain = $1 and u.email = $2`, [subdomain, email]);
  if (!rows.length) return res.status(403).json({ error: `You are not a member of ${subdomain}` });
  req.tenant = rows[0];
  next();
}

app.use('/api', resolveTenant);

app.get('/api/projects', async (req, res) => {
  const { rows } = await withTenant(req.tenant.id, c => c.query('select id, name from projects order by id'));
  res.json({ tenant: req.tenant.name, projects: rows });
});

app.post('/api/projects', async (req, res) => {
  try {
    // tenant_id is filled in by the database from the session, never from req.body
    const { rows } = await withTenant(req.tenant.id, c =>
      c.query('insert into projects (name) values ($1) returning id, name', [req.body.name]));
    res.status(201).json(rows[0]);
  } catch (err) {
    if (err.code === 'P0001') return res.status(402).json({ error: err.message });
    throw err;
  }
});

// The retrieval step behind an AI assistant: only this tenant's documents can come back
app.get('/api/search', async (req, res) => {
  const { rows } = await withTenant(req.tenant.id, c => c.query(
    `select title, left(body, 70) as snippet from documents
      where search @@ websearch_to_tsquery('english', $1)
      order by ts_rank(search, websearch_to_tsquery('english', $1)) desc limit 3`, [req.query.q]));
  res.json({ tenant: req.tenant.name, results: rows });
});

app.listen(4404, () => console.log('Fieldnote API on :4404'));
The database fills in tenant_id on insert and raises the plan-limit error; the API turns it into a 402.

Acme is on the Starter plan, which allows three projects. Its owner adds two, then tries a fourth:

Terminal: listing projects, creating two more, then a fourth request rejected with Plan limit reached: Starter allows 3 projects and HTTP 402
The limit comes from the database, so it holds even for routes nobody remembered to check.

Then the isolation checks. Acme’s owner tries Globex’s subdomain, Globex’s owner sends Acme’s tenant id in the request body, and both search for “refund”:

Terminal: 403 for a non-member; a project created with another tenant id in the body lands in the sender’s own tenant; each tenant’s search returns only its own refund policy
“Planted” ends up in Globex, where its creator belongs. The tenant id in the body was ignored.

Step 4: Background jobs and AI search need the tenant too

Teams usually get the main tables right. The leaks happen elsewhere: a background job that emails one customer’s report to another, a cache keyed without the tenant, or an AI assistant whose document search isn’t filtered. In the API above, the search endpoint is the retrieval step an assistant would use, and it runs inside withTenant like everything else. The same applies to vector search with pgvector: the embeddings table gets a tenant_id and the same policy.

javascript
// Background jobs carry the tenant they work for, exactly like requests do.
import { pool, withTenant } from './db.mjs';

const { rows: tenants } = await pool.query('select id, name from tenants order by name');

for (const t of tenants) {
  const { rows } = await withTenant(t.id, c => c.query('select count(*)::int as n from projects'));
  console.log(`weekly digest for ${t.name}: ${rows[0].n} project(s)`);
}

// The bug everyone writes eventually: a job that forgets to set the tenant
const { rows } = await pool.query('select count(*)::int as n from projects');
console.log(`job without a tenant sees ${rows[0].n} project(s)  <- fails closed, not open`);

await pool.end();
Terminal: weekly digest for Acme Studio with 3 projects and Globex Agency with 2; a job without a tenant sees 0 projects
A job that forgets the tenant gets nothing back, which is the failure you want.

Step 5: Check who the app connects as

This one catches experienced teams. PostgreSQL doesn’t apply row-level security to superusers, or to a table’s owner unless the table is set to FORCE ROW LEVEL SECURITY. If your app connects as the user that ran the migrations, every policy above is silently skipped.

Terminal: as postgres the count is 5; as saas_app with no tenant set the count is 0
Same query, same table. The owner sees every tenant’s rows.

Running it once customers arrive

  • When support needs to look inside a customer’s account, make that access explicit, time-limited and logged.
  • Watch usage per tenant, because one very large customer can slow everyone else down.
  • Tie each tenant to a subscription in Stripe and keep its plan and limits in your own database, as above.
  • Build data export and deletion per tenant early; customers will ask, sometimes because a regulator told them to.
  • Test database migrations against a copy of your largest tenant’s data, not an empty development database.

What shapes the cost

FactorWhy it matters
Isolation modelShared tables are cheapest; dedicated databases add operations work per customer.
Sign-in requirementsSSO per customer (Okta, Entra ID) adds setup and testing.
BillingPlans, trials, upgrades, downgrades and failed payments each need handling.
AI featuresTenant-scoped retrieval, usage limits and cost tracking per tenant.
Migrating an existing appAdding tenant_id, backfilling data and switching on policies table by table.

Mistakes we’d avoid

Where we’d start

For most new SaaS products we start with Next.js, Supabase or PostgreSQL with row-level security for data, sign-in and storage, Stripe for billing, and a background job runner that always knows which tenant it’s working for. It’s quick to build on, cheap at the start, and leaves room for dedicated databases if enterprise customers need them, such as those who sign in with Okta. More on how we work in AI full-stack development.

Sources

Spotted something out of date? Tell us

Questions we often hear

Shared database or a database per tenant?

Shared tables with row-level security for most products. Separate databases make sense for a small number of large customers with strict isolation or data-location requirements.

Is row-level security enough on its own?

It’s the foundation. You still need tenant resolution on the server, tenant-aware storage, caches, jobs and AI search, an app role that isn’t the table owner, and tests that prove tenants can’t see each other’s data.

Can you add multi-tenancy to an app we already have?

Yes. We add a tenant to every table, backfill the existing data, switch on policies table by table and test thoroughly before going live.

Building a SaaS product?

Tell us who your customers are and what they’ll keep in the product. On a free 30-minute call an engineer will sketch the tenancy model we’d use.