The first version of a multi-tenant feature I built had a bug where one clinic's queue data briefly showed up in another clinic's dashboard. Nothing malicious, just a missing filter on one query, but it was the kind of mistake that makes clear how unforgiving multi-tenancy actually is. Get isolation wrong once, and you are not looking at a cosmetic bug, you are looking at one customer's data appearing in front of another.
Here is how I structure it now to make that mistake much harder to make.
1. What Multi-Tenancy Actually Means
A multi-tenant SaaS serves multiple separate customers, tenants, clinics, companies, teams, from one shared application and often one shared database, while keeping each tenant's data completely isolated from every other tenant's.
The alternative, a fully separate database per tenant, is more isolated by default but far more operationally expensive to run and maintain at any real scale. For most SaaS products, a shared database with tenant scoping enforced at the application layer is the more practical starting point.
2. Scoping Every Model to a Tenant
Every collection that holds tenant-specific data needs a tenantId (or clinicId, organizationId, whatever fits the domain) field, indexed, and referenced on every single query without exception.
// models/QueueEntry.ts
import { Schema, model, models } from 'mongoose';
const QueueEntrySchema = new Schema({
tenantId: {
type: Schema.Types.ObjectId,
ref: 'Tenant',
required: true,
index: true,
},
patientName: { type: String, required: true },
status: { type: String, enum: ['waiting', 'called', 'done'], default: 'waiting' },
position: Number,
}, { timestamps: true });
export const QueueEntry = models.QueueEntry || model('QueueEntry', QueueEntrySchema);
Indexing tenantId matters beyond correctness, every query is going to filter by it, so it needs to actually be fast once a tenant's data grows.
3. Centralizing Tenant Scoping Instead of Repeating It
The dangerous version of multi-tenancy is remembering to add { tenantId } to every single query by hand, across every file, forever. One missed filter is exactly the kind of bug that leaks data between tenants. Centralizing it removes that risk from individual query authors.
// lib/tenant-scope.ts
import { getSession } from '@/lib/auth';
export async function getTenantId(): Promise<string> {
const session = await getSession();
if (!session?.tenantId) {
throw new Error('No tenant context available');
}
return session.tenantId;
}
// lib/queries/queue.ts
import { getTenantId } from '@/lib/tenant-scope';
import { QueueEntry } from '@/models/QueueEntry';
export async function getQueue() {
const tenantId = await getTenantId();
return QueueEntry.find({ tenantId }).sort({ position: 1 }).lean();
}
export async function callNext() {
const tenantId = await getTenantId();
return QueueEntry.findOneAndUpdate(
{ tenantId, status: 'waiting' },
{ status: 'called' },
{ sort: { position: 1 }, new: true }
);
}
Every query function pulls tenantId from the same place, the current session, rather than trusting a value passed in from the client, which could be tampered with. The tenant boundary is enforced server-side, based on who is actually logged in, not on anything the request itself claims.
4. Never Trust a Tenant ID from the Client
This is the mistake that actually causes cross-tenant data leaks in practice. A tenant ID sent as a form field, a query parameter, or a request body value can be edited by anyone who opens dev tools, if the server blindly trusts it.
// โ Trusting a client-supplied tenant ID
export async function getQueue(tenantId: string) {
return QueueEntry.find({ tenantId }).lean(); // tenantId came from the request
}
// โ
Deriving tenant ID from the authenticated session, server-side only
export async function getQueue() {
const tenantId = await getTenantId(); // from the session, never the request
return QueueEntry.find({ tenantId }).lean();
}
The only place a tenant ID should ever come from is the authenticated session on the server. If a query function accepts a tenant ID as a parameter passed in from outside, that is the exact shape of bug that leaks one tenant's data into another's view.
5. Subdomain-Based Tenant Routing
For a SaaS where each tenant gets their own subdomain, clinicname.yourapp.com, middleware resolves which tenant a request belongs to before it reaches any page.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const host = request.headers.get('host') || '';
const subdomain = host.split('.')[0];
if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
const response = NextResponse.next();
response.headers.set('x-tenant-subdomain', subdomain);
return response;
}
return NextResponse.next();
}
// lib/tenant-scope.ts
import { headers } from 'next/headers';
export async function getTenantFromSubdomain() {
const headersList = await headers();
const subdomain = headersList.get('x-tenant-subdomain');
if (!subdomain) throw new Error('No tenant subdomain found');
const tenant = await Tenant.findOne({ subdomain }).lean();
if (!tenant) throw new Error('Tenant not found');
return tenant;
}
This resolves which tenant a request belongs to purely from the URL, before any session even needs to be checked, useful for public-facing tenant pages that do not require login, a clinic's public queue display, for example.
6. Combining Subdomain and Session-Based Scoping
For authenticated dashboard routes, both checks matter together, the subdomain tells you which tenant's app is being accessed, and the session confirms the logged-in user actually belongs to that tenant.
// lib/tenant-scope.ts
export async function requireTenantAccess() {
const session = await getSession();
if (!session) redirect('/login');
const tenant = await getTenantFromSubdomain();
if (session.tenantId !== tenant._id.toString()) {
// Logged in, but to a different tenant than the one being accessed
redirect('/unauthorized');
}
return { session, tenant };
}
Without this second check, a user logged into one tenant could potentially navigate to another tenant's subdomain and, depending on how session cookies are scoped, end up interacting with data that does not belong to them. Checking both explicitly closes that gap.
7. Tenant-Scoped Rate Limiting and Usage Limits
Multi-tenant SaaS often needs per-tenant limits, a free plan capped at a certain number of records, an API limit per tenant rather than per user.
// lib/queries/queue.ts
export async function addToQueue(patientName: string) {
const tenantId = await getTenantId();
const tenant = await Tenant.findById(tenantId).lean();
const currentCount = await QueueEntry.countDocuments({ tenantId, status: 'waiting' });
if (tenant.plan === 'free' && currentCount >= 20) {
throw new Error('Queue limit reached for your plan');
}
return QueueEntry.create({ tenantId, patientName, status: 'waiting' });
}
Checking the plan limit inside the same function that creates the record, rather than as a separate step that could be skipped, keeps the limit actually enforced everywhere the record could be created.
Summary
| Pattern | Handles |
|---|---|
tenantId on every tenant-scoped model, indexed |
Data isolation at the schema level |
Centralized getTenantId() from session |
Removing the risk of a manually forgotten filter |
| Never trusting a client-supplied tenant ID | The actual cause of most real cross-tenant data leaks |
| Subdomain middleware resolution | Routing and public pages scoped per tenant, pre-auth |
| Combined subdomain + session check | Preventing a logged-in user from reaching another tenant's data |
| Plan limits enforced inside the write function | Usage limits that cannot be bypassed by skipping a separate check |
The single rule that matters most: a tenant ID is a security boundary, not just a filter. It should only ever come from something the server itself can verify, the authenticated session, never from anything the client sends in a request. Every real multi-tenancy bug I have seen traces back to that boundary being trusted from the wrong source.
I use this exact pattern, session-derived tenant scoping, subdomain routing, plan limits enforced at the write layer, in the SaaS products I build.
Get the templates: https://pixelanas.gumroad.com
Have you built multi-tenant features before? What tripped you up first? Drop it below ๐
Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751
Top comments (0)