Every SaaS product eventually answers the same question: how do many customers share one product without ever seeing each other's data? The answer is multi-tenant architecture, and the decisions you make early on, especially around the database, are expensive to change later.
At TrackMy Tech we build Track My Academy, a management platform for sports academies, where each academy is a tenant. This guide covers the patterns we think matter most: choosing an isolation model, resolving the tenant on every request, enforcing isolation in more than one layer, and avoiding the mistakes that cause cross-tenant leaks.
A note on the examples: Track My Academy itself runs on Node.js, Express and MongoDB (MongoDB Atlas), with a tenant id on every document. Many teams building SaaS products use a relational database, though, so most examples below use PostgreSQL, including Row-Level Security, to illustrate the patterns for those stacks. Where it helps, we show how the same idea looks in MongoDB. The principles carry over either way.
What multi-tenancy means
A tenant is a customer organisation: an academy, a company, a school. In a multi-tenant system, one deployment of your application serves all tenants, and each tenant's data is isolated logically rather than by running separate copies of the software.
It helps to think of isolation as a spectrum:
- Single-tenant (silo): each customer gets its own deployment and database. Isolation is strongest, but every customer adds operational work: separate deployments, upgrades and monitoring.
- Multi-tenant (pool): everyone shares the application and infrastructure. You deploy once and every tenant gets the update, and resources are used efficiently.
- Hybrid: most tenants share pooled infrastructure, while a few with strict compliance or performance needs get dedicated resources.
Whichever point you choose, three concerns need deliberate design:
- Data isolation: tenant data never crosses boundaries.
- Performance isolation: one busy tenant cannot degrade everyone else (the "noisy neighbour" problem).
- Configuration: tenants can have different plans, limits and features without forking the codebase.
Choosing a data isolation model
This is the most consequential decision. There are three common approaches.
1. Database per tenant
Each tenant gets its own database. Isolation is strong, per-tenant backup, restore and deletion are simple, and one tenant's heavy queries cannot slow the others.
import pg from 'pg';
const pools = new Map();
export async function getTenantPool(tenantId) {
if (!pools.has(tenantId)) {
// Look up connection details in a central tenant directory
const cfg = await tenantDirectory.getDatabaseConfig(tenantId);
pools.set(
tenantId,
new pg.Pool({
host: cfg.host,
database: cfg.database,
user: cfg.user,
password: cfg.password,
ssl: { rejectUnauthorized: true },
max: 5, // keep per-tenant pools small
})
);
}
return pools.get(tenantId);
}
Pros: strongest isolation, easy per-tenant backup and deletion, clear performance boundaries. Cons: more infrastructure to run, migrations must run against every database, connection counts grow with tenant count, and cross-tenant reporting needs a separate data pipeline.
2. Schema per tenant
All tenants share a PostgreSQL server, but each gets its own schema containing the same tables. It is a middle ground, but it has a trap: search_path is a session setting. With a connection pool, a SET search_path made for one request can leak into the next request that reuses the same connection. Scope it to a transaction instead:
const SCHEMA_RE = /^tenant_[a-z0-9_]+$/;
export async function withTenantSchema(pool, schema, fn) {
if (!SCHEMA_RE.test(schema)) throw new Error('Invalid tenant schema');
const client = await pool.connect();
try {
await client.query('BEGIN');
// is_local = true: the setting ends with this transaction
await client.query("SELECT set_config('search_path', $1, true)", [schema]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
// Usage
app.get('/api/students', authenticate, resolveTenant, async (req, res) => {
const { rows } = await withTenantSchema(pool, req.tenant.schema, (db) =>
db.query('SELECT id, name FROM students WHERE active = true')
);
res.json(rows);
});
Pros: good isolation with one database server; per-tenant restore is possible. Cons: migrations run once per schema, and catalog size and migration time grow with tenant count. Very large numbers of schemas become hard to manage.
3. Shared tables with a tenant_id column
All tenants share the same tables, and every row carries a tenant_id. This is the most common model and the most resource-efficient, and it is the model Track My Academy is built on (in MongoDB terms: shared collections, with a tenant id on every document). It also puts the most responsibility on your code, so pair it with a second layer of enforcement. The relational version looks like this:
CREATE TABLE students (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id integer NOT NULL REFERENCES tenants(id),
name text NOT NULL,
email text NOT NULL,
active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (tenant_id, email) -- uniqueness is per tenant
);
-- Indexes lead with tenant_id
CREATE INDEX idx_students_tenant_created ON students (tenant_id, created_at);
A repository that always takes its tenant from the server side makes the filter hard to forget:
export class StudentRepository {
constructor(db, tenantId) {
this.db = db;
this.tenantId = tenantId; // from the verified session, never the request body
}
async findById(studentId) {
const { rows } = await this.db.query(
'SELECT * FROM students WHERE id = $1 AND tenant_id = $2',
[studentId, this.tenantId]
);
if (rows.length === 0) throw new NotFoundError('Student not found');
return rows[0];
}
async create({ name, email }) {
const { rows } = await this.db.query(
'INSERT INTO students (tenant_id, name, email) VALUES ($1, $2, $3) RETURNING *',
[this.tenantId, name, email]
);
return rows[0];
}
async delete(studentId) {
const { rowCount } = await this.db.query(
'DELETE FROM students WHERE id = $1 AND tenant_id = $2',
[studentId, this.tenantId]
);
if (rowCount === 0) throw new NotFoundError('Student not found');
}
}
In MongoDB, which is what Track My Academy uses, the equivalent is a required tenantId on every document and compound indexes that start with it:
// models/Student.js (Mongoose)
const studentSchema = new mongoose.Schema({
tenantId: { type: mongoose.Schema.Types.ObjectId, ref: 'Tenant', required: true },
name: { type: String, required: true },
email: { type: String, required: true },
active: { type: Boolean, default: true },
}, { timestamps: true });
studentSchema.index({ tenantId: 1, email: 1 }, { unique: true }); // uniqueness is per tenant
studentSchema.index({ tenantId: 1, createdAt: -1 });
// Every query includes the tenant from the verified session
const student = await Student.findOne({ _id: studentId, tenantId: req.tenant.id });
Pros: cheapest to run, simple migrations and connection management, easy cross-tenant analytics for your own team. Cons: a missing filter can leak data, large tenants share resources with small ones, and per-tenant restore is harder.
Our recommendation
For most new SaaS products on a relational database, start with shared tables plus Row-Level Security, and keep a path open to move large or regulated tenants to a dedicated database later. To make that move possible, keep a tenant directory (which tenant lives where) from day one, rather than hard-coding a single connection string.
Enforcing isolation in the database with Row-Level Security
Application filters are your first line of defence. If you are on PostgreSQL, Row-Level Security (RLS) is the safety net that catches the query someone forgot to filter.
MongoDB has no direct equivalent of RLS, so in Track My Academy the second layer lives in the application instead: a shared data-access layer adds the tenant filter to every query, so leaving it out is the hard thing to do, and isolation tests (see below) check every endpoint. The rest of this section is for teams on PostgreSQL.
ALTER TABLE students ENABLE ROW LEVEL SECURITY;
ALTER TABLE students FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY tenant_isolation ON students
USING (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.current_tenant', true), '')::integer);
Set the tenant per transaction, not per session, for the same pooling reason as search_path:
export async function withTenant(pool, tenantId, fn) {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query("SELECT set_config('app.current_tenant', $1, true)", [
String(tenantId),
]);
const result = await fn(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Run repository calls inside this helper, for example withTenant(pool, tenantId, (db) => new StudentRepository(db, tenantId).findById(id)), so the explicit tenant_id filter and the RLS policy both apply. (The shorter route examples in this post pass a db handle for brevity.)
Points that are easy to get wrong:
- Roles. Superusers and roles with
BYPASSRLSignore policies, even withFORCE. Your application should connect as an ordinary role that does not own the tables. Run migrations with a separate owner role. - Missing context. With
NULLIF(..., ''), an unset tenant matches no rows instead of raising a cast error, so a missing context fails closed. - Transaction-mode poolers. With PgBouncer in transaction mode, session-level
SETis unsafe. Transaction-localset_config(..., true)works. - Every tenant table. Add the policy to every table that holds tenant data, and include this in your migration review checklist.
Resolving the tenant on every request
Before you can isolate data, you need to know which tenant a request belongs to. Common strategies:
- Subdomain:
academy-name.example.com. This is the most common choice for web apps, and it makes tenant branding easy. - Path:
example.com/academy-name/dashboard. This is simpler for DNS and TLS, but every route needs the prefix. - Token claim: the tenant id is in the signed access token. This suits APIs and mobile apps.
A tenant hint from the URL is only a hint. The authenticated user must belong to that tenant, and the server should derive the tenant from the verified session or token, never from a request header or body the client controls.
export async function resolveTenant(req, res, next) {
const subdomain = req.hostname.split('.')[0]; // configure 'trust proxy' correctly behind a load balancer
let tenant = await tenantCache.get(subdomain);
if (!tenant) {
const { rows } = await db.query(
'SELECT id, name, subdomain, plan, state FROM tenants WHERE subdomain = $1',
[subdomain]
);
tenant = rows[0];
if (tenant) await tenantCache.set(subdomain, tenant);
}
if (!tenant) return res.status(404).json({ error: 'Not found' });
if (req.user && req.user.tenantId !== tenant.id) {
return res.status(404).json({ error: 'Not found' });
}
req.tenant = tenant;
next();
}
Propagating tenant context
Passing tenantId through every function call is error-prone. Node's AsyncLocalStorage makes it available anywhere in the request's async call chain:
import { AsyncLocalStorage } from 'node:async_hooks';
export const tenantContext = new AsyncLocalStorage();
export function establishTenantContext(req, res, next) {
tenantContext.run({ tenantId: req.tenant.id, userId: req.user?.id }, next);
}
export function currentTenantId() {
const ctx = tenantContext.getStore();
if (!ctx?.tenantId) throw new Error('No tenant context: refusing to query');
return ctx.tenantId;
}
On the front end, a small React context keeps the current tenant available to components:
import { createContext, useContext } from 'react';
const TenantContext = createContext(null);
export function TenantProvider({ tenant, children }) {
return <TenantContext value={tenant}>{children}</TenantContext>; // React 19 syntax
}
export function useTenant() {
const tenant = useContext(TenantContext);
if (!tenant) throw new Error('useTenant must be used inside TenantProvider');
return tenant;
}
Authorisation inside a tenant
Tenant isolation decides whose data you can reach. Role-based access control decides what you can do with it. Keep the two checks separate and explicit:
const permissions = {
'student:read': ['admin', 'coach', 'staff'],
'student:write': ['admin', 'coach'],
'student:delete': ['admin'],
'report:generate': ['admin', 'coach'],
};
export const requirePermission = (permission) => (req, res, next) => {
if (!req.user || req.user.tenantId !== req.tenant.id) {
return res.status(404).json({ error: 'Not found' });
}
if (!(permissions[permission] ?? []).includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
app.delete(
'/api/students/:id',
authenticate,
resolveTenant,
requirePermission('student:delete'),
async (req, res) => {
await new StudentRepository(db, req.tenant.id).delete(req.params.id);
res.status(204).end();
}
);
Return 404 rather than 403 for resources in another tenant, so attackers cannot probe which IDs exist.
Scaling: noisy neighbours, caching and indexes
Large tenants
Tenants differ a lot in size. An academy with thousands of students produces a very different load from one with a few dozen. Practical steps:
- Measure per tenant. Tag logs, metrics and slow-query reports with the tenant id so you can see who is generating the load.
- Rate-limit per tenant, not just per IP, so one tenant's integration or scraper cannot starve the rest.
- Use a tenant directory for placement. Store each tenant's database or shard in a table, and move a heavy tenant to dedicated resources by updating that row and migrating its data. Avoid hash-based placement (
tenantId % N), because changing N reshuffles everyone.
Tenant-aware caching
Every cache key must include the tenant:
const key = (tenantId, ...parts) => ['tenant', tenantId, ...parts].join(':');
export async function getStudentCached(tenantId, studentId) {
const cacheKey = key(tenantId, 'student', studentId);
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const student = await new StudentRepository(db, tenantId).findById(studentId);
await redis.set(cacheKey, JSON.stringify(student), { EX: 3600 }); // node-redis v4+
return student;
}
export async function invalidateStudent(tenantId, studentId) {
await redis.del([key(tenantId, 'student', studentId), key(tenantId, 'students', 'list')]);
}
The same rule applies to CDN caches, file storage paths (tenants/{id}/...) and search indexes.
Indexes
In shared tables, nearly every query filters by tenant first, so composite indexes should usually lead with tenant_id:
-- Less useful for tenant-scoped lookups
CREATE INDEX idx_students_email ON students (email, tenant_id);
-- Better: tenant first, then the columns you filter or sort by
CREATE INDEX idx_students_tenant_email ON students (tenant_id, email);
CREATE INDEX idx_enrollments_tenant_student ON enrollments (tenant_id, student_id);
-- Partial index when most queries target active students only
CREATE INDEX idx_students_tenant_active ON students (tenant_id, created_at)
WHERE active = true;
Check real plans with EXPLAIN (ANALYZE, BUFFERS) against a large tenant, not just your test data.
Pitfalls we see most often
Trusting a client-supplied tenant id
// Vulnerable: the client chooses the tenant
const { tenantId, name, email } = req.body;
// Safe: the server derives it from the verified session
const repo = new StudentRepository(db, req.tenant.id);
await repo.create({ name: req.body.name, email: req.body.email });
Background jobs without tenant context
Jobs have no HTTP request, so tenant context must travel in the job payload and be re-established:
queue.process('generate-report', async (job) => {
const { tenantId, studentIds } = job.data;
if (!tenantId) throw new Error('Job missing tenant context');
return withTenant(pool, tenantId, (db) =>
db.query(
'SELECT * FROM students WHERE id = ANY($1) AND tenant_id = $2',
[studentIds, tenantId]
)
);
});
Side effects inside the provisioning transaction
Tenant sign-up should be fast and atomic. A common bug is enqueuing work, or sending email, before the transaction commits. If the transaction rolls back, the job still runs for a tenant that does not exist:
export async function provisionTenant({ name, subdomain, plan, adminEmail }) {
const client = await pool.connect();
let tenant;
try {
await client.query('BEGIN');
({ rows: [tenant] } = await client.query(
`INSERT INTO tenants (name, subdomain, plan, state, trial_ends_at)
VALUES ($1, $2, $3, 'trial', now() + interval '30 days') RETURNING *`,
[name, subdomain, plan]
));
await client.query(
'INSERT INTO users (tenant_id, email, role) VALUES ($1, $2, $3)',
[tenant.id, adminEmail, 'admin']
);
await seedDefaultData(client, tenant.id); // same transaction
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
// Only after commit: slow or external work
await queue.add('provision-tenant-resources', { tenantId: tenant.id });
return tenant;
}
For stronger guarantees, write the job to an outbox table inside the transaction and have a worker publish it.
Lifecycle states that lock out trials
Model tenant states explicitly (trial, active, suspended, cancelled) and make sure access checks allow every state that should have access:
const ALLOWED_STATES = new Set(['trial', 'active']);
export function requireUsableTenant(req, res, next) {
if (!ALLOWED_STATES.has(req.tenant.state)) {
return res.status(402).json({ error: 'Subscription inactive', state: req.tenant.state });
}
next();
}
Plan logic scattered through the code
Keep plan features and limits in one place, so a new plan is a configuration change, not a hunt through the codebase:
const PLANS = {
standard: { features: [], maxUsers: 100 },
professional: { features: ['advanced-analytics', 'api-access'], maxUsers: 500 },
enterprise: { features: ['advanced-analytics', 'api-access', 'custom-branding'], maxUsers: Infinity },
};
export const hasFeature = (tenant, feature) =>
PLANS[tenant.plan]?.features.includes(feature) ?? false;
Enforce limits on the server. Hiding a button in the UI is not access control.
Automated isolation tests
The single most valuable test in a multi-tenant codebase is the one that tries to read another tenant's data:
describe('tenant isolation', () => {
it('does not expose another tenant’s student', async () => {
const otherStudent = await createStudent({ tenantId: 2, name: 'Bob' });
const token = await loginAs({ tenantId: 1, role: 'admin' });
const res = await request(app)
.get(`/api/students/${otherStudent.id}`)
.set('Host', 'tenant-one.example.test')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(404); // not 403: don't reveal that it exists
});
});
Write one for every endpoint that takes an ID, and run them in CI.
Implementation checklist
Data
- Isolation model chosen, with a tenant directory recording where each tenant lives
-
tenant_idon every tenant-owned table; uniqueness constraints scoped per tenant - PostgreSQL: RLS enabled and forced on tenant tables; app role is not the owner and lacks
BYPASSRLS(MongoDB: tenant filter enforced in a shared data-access layer) - Tenant context set per transaction, never per pooled session
- Composite indexes lead with
tenant_id
Identity and access
- Tenant derived from the verified session or token, never from client input
- User-to-tenant membership checked on every request
- RBAC within each tenant; cross-tenant lookups return 404
Operations
- Tenant id in every cache key, storage path, log line and metric
- Per-tenant rate limits and slow-query visibility
- Background jobs carry and re-establish tenant context
- Provisioning is transactional; side effects happen after commit
- Per-tenant export and deletion procedures for data-protection requests, following India's Digital Personal Data Protection (DPDP) Act, GDPR and similar laws
- Isolation tests in CI for every ID-based endpoint
Conclusion
Multi-tenancy is less about picking the perfect pattern and more about making isolation hard to get wrong. Start with shared tables (or shared collections), back them with a second layer of enforcement such as PostgreSQL Row-Level Security or a shared data-access layer, derive the tenant only from trusted sources, carry tenant context into every cache, job and log, and test isolation continuously. Keep a tenant directory so you can give large or regulated customers dedicated resources when they need them.
These are the principles we apply in Track My Academy, which runs on MongoDB with a tenant id on every document. If you run an academy, you can try Track My Academy or book a free demo. If you are planning a SaaS product and want help with the architecture, see our SaaS platform development services or book a free consultation with TrackMy Tech.
