How to Structure a Multi-Tenant SaaS Backend
A practical guide to multi-tenant SaaS architecture — from database isolation strategies to auth, billing, and feature flags.

The moment you decide your app will serve more than one organization, every architectural decision forks. Database design, authentication, authorization, billing, even logging — all of it now carries the question: for which tenant?
I've shipped multi-tenant backends across very different domains. The patterns that survive production are rarely the ones that look cleanest on a whiteboard. This post covers the decisions that actually matter, the tradeoffs nobody warns you about, and concrete implementation patterns you can steal.
Database Isolation Strategies
This is the first and most consequential decision. Get it wrong and you're looking at a migration that touches every query in your codebase.
There are three main approaches:
| Strategy | Isolation | Complexity | Cost | Best For |
|---|---|---|---|---|
| Shared DB, shared schema | Low | Low | Lowest | Early-stage SaaS, < 100 tenants |
| Shared DB, schema-per-tenant | Medium | Medium | Medium | Mid-scale, regulated industries |
| Database-per-tenant | High | High | Highest | Enterprise clients, strict compliance |
Shared Database, Shared Schema
Every tenant's data lives in the same tables. You distinguish rows by a tenant_id column. This is where most SaaS products should start.
The upside is simplicity. One migration path, one connection pool, straightforward queries. The downside is that a missing WHERE tenant_id = ? clause means a data leak. There's no safety net at the infrastructure level — isolation is entirely in your application code.
This approach works until a large tenant's queries start degrading performance for everyone else, or until an enterprise customer's compliance team asks how their data is physically separated from competitors. At that point, you graduate.
Schema-per-Tenant
Each tenant gets their own PostgreSQL schema (or equivalent) within a shared database instance. Tables are identical across schemas, but physically separated. You switch the search path at connection time.
This gives you real isolation without managing dozens of database instances. Migrations are more involved — you're running them N times — but tools like node-pg-migrate or Prisma's multi-schema support handle this reasonably well.
The catch: connection pooling gets tricky. If you're using PgBouncer, you need to be deliberate about how schema switching interacts with pooled connections. I've seen this cause subtle bugs where Tenant A momentarily reads Tenant B's schema because a connection was returned to the pool mid-transaction.
Database-per-Tenant
Maximum isolation. Each tenant has a dedicated database instance (or at least a dedicated logical database). You route connections dynamically based on tenant context.
This is expensive and operationally heavy, but some customers will pay for it. Healthcare and financial services often require it. The key benefit beyond isolation is that you can scale, back up, and restore tenants independently. A noisy neighbor can't tank your shared instance.
In practice, most teams run a hybrid: shared DB for the majority, dedicated instances for enterprise accounts that pay for it.
Authentication & Tenant Resolution
Every inbound request needs to answer two questions: who is this user? and which tenant do they belong to?
There are three common strategies for resolving tenant context:
Subdomain-based: acme.yourapp.com maps to the Acme tenant. Clean, intuitive, works great for B2B products where each customer gets their own workspace. You extract the tenant slug from the Host header.
JWT claim-based: The tenant ID is embedded in the access token at login time. No infrastructure-level routing needed. Works well for mobile apps and SPAs where subdomain routing is impractical.
Header-based: The client sends an X-Tenant-ID header. Simple but dangerous if not validated against the authenticated user's permissions. Only use this for internal service-to-service calls.
In most systems, I combine subdomain resolution for the initial login flow with JWT claims for subsequent API calls. Here's what the middleware looks like:
import { Request, Response, NextFunction } from "express";
import { verifyToken } from "../lib/auth";
import { getTenantBySlug } from "../services/tenant";
export async function tenantMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// 1. Try JWT claim first (authenticated requests)
const token = req.headers.authorization?.replace("Bearer ", "");
if (token) {
const payload = verifyToken(token);
if (payload?.tenantId) {
req.tenantId = payload.tenantId;
return next();
}
}
// 2. Fall back to subdomain resolution
const host = req.hostname;
const slug = host.split(".")[0];
if (slug === "www" || slug === "app") {
return res.status(400).json({ error: "Tenant not identified" });
}
const tenant = await getTenantBySlug(slug);
if (!tenant || !tenant.isActive) {
return res.status(404).json({ error: "Tenant not found" });
}
req.tenantId = tenant.id;
next();
}
Mount this before your route handlers and every downstream function has access to req.tenantId. The rest of your application never has to think about how tenant resolution works — it just reads the ID from the request context.
Scoping Every Database Query
Once you have tenant context on every request, you need to guarantee it's applied to every query. Missing a scope is a data breach. This isn't the kind of thing you leave to developer discipline.
The best pattern I've found is a repository layer that enforces scoping automatically:
import { db } from "../lib/database";
export function scopedQuery(tenantId: string) {
return {
async findMany<T>(table: string, where: Record<string, unknown> = {}): Promise<T[]> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.select("*");
},
async findOne<T>(table: string, where: Record<string, unknown>): Promise<T | null> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.first();
},
async insert<T>(table: string, data: Record<string, unknown>): Promise<T> {
return db(table)
.insert({ ...data, tenant_id: tenantId })
.returning("*")
.then((rows) => rows[0]);
},
async update(
table: string,
where: Record<string, unknown>,
data: Record<string, unknown>
): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.update(data);
},
async remove(table: string, where: Record<string, unknown>): Promise<number> {
return db(table)
.where({ ...where, tenant_id: tenantId })
.del();
},
};
}
In your route handlers, you never construct raw queries. You always go through the scoped interface:
app.get("/api/projects", async (req, res) => {
const query = scopedQuery(req.tenantId);
const projects = await query.findMany("projects", { archived: false });
res.json(projects);
});
If you're using an ORM like Prisma, you can achieve the same thing with middleware that injects the tenant_id filter into every findMany, findFirst, update, and delete call. The point is to make the insecure path harder than the secure one.
Role-Based Access Control Within Tenants
Multi-tenancy introduces a two-layer authorization model. First: does this user belong to this tenant? Second: what can they do within it?
I keep this straightforward with a tenant_memberships table:
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id),
role VARCHAR(50) NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
Roles are typically owner, admin, member, and sometimes viewer or a domain-specific role. Avoid building a full permission matrix unless your product genuinely needs it — most B2B SaaS does fine with 3-4 roles.
The authorization check happens after tenant resolution:
export function requireRole(...roles: string[]) {
return async (req: Request, res: Response, next: NextFunction) => {
const membership = await db("tenant_memberships")
.where({ user_id: req.userId, tenant_id: req.tenantId })
.first();
if (!membership || !roles.includes(membership.role)) {
return res.status(403).json({ error: "Insufficient permissions" });
}
req.userRole = membership.role;
next();
};
}
// Usage
app.delete("/api/projects/:id", requireRole("owner", "admin"), async (req, res) => {
// only owners and admins reach this handler
});
One design choice that saves headaches later: always allow a user to belong to multiple tenants. Even if your product is single-workspace today, the moment you add agency accounts, white-label resellers, or a consultant mode, you'll need it.
Feature Flags per Tenant
Not every tenant should see every feature. Sometimes you're gating by plan tier. Sometimes you're running a gradual rollout. Sometimes an enterprise client is paying for a custom capability.
I use a simple tenant_features table rather than pulling in a third-party feature flag service. For most SaaS products, this is more than enough:
// Cache-backed feature flag check
const featureCache = new Map<string, Set<string>>();
export async function hasFeature(tenantId: string, feature: string): Promise<boolean> {
if (!featureCache.has(tenantId)) {
const rows = await db("tenant_features")
.where({ tenant_id: tenantId, enabled: true })
.select("feature_key");
featureCache.set(tenantId, new Set(rows.map((r) => r.feature_key)));
}
return featureCache.get(tenantId)!.has(feature);
}
// Invalidate on plan change or manual toggle
export function invalidateFeatureCache(tenantId: string) {
featureCache.delete(tenantId);
}
In route handlers:
app.post("/api/analytics/export", async (req, res) => {
if (!(await hasFeature(req.tenantId, "analytics_export"))) {
return res.status(403).json({
error: "This feature is not available on your current plan",
upgrade: true,
});
}
// proceed with export
});
When a tenant upgrades their plan, you flip the relevant feature rows and invalidate the cache. Straightforward, auditable, and no external dependencies.
Billing Integration
Stripe is the standard here for good reason. The mapping is natural: one Stripe Customer per tenant, one Subscription per tenant, plan tiers map to Products and Prices.
The critical design rule: store the Stripe Customer ID on the tenant record, not the user record. Billing belongs to the organization, not the person who entered the credit card.
The webhook flow looks like this:
checkout.session.completed— attach the Stripe Customer ID to the tenant, activate their subscription.invoice.paid— extend the billing period, updatetenant.paidUntil.invoice.payment_failed— mark the tenant as past-due, send dunning emails.customer.subscription.updated— sync plan tier changes, update feature flags accordingly.customer.subscription.deleted— downgrade to free tier or suspend.
The key is idempotency. Stripe can and will send the same webhook multiple times. Every handler should be safe to run twice with the same event payload.
Data Isolation & Security
Beyond query scoping, there are a few non-obvious things you should get right early:
Row-Level Security (RLS): If you're on PostgreSQL, enable RLS as a second line of defense. Even if your application code has a bug, the database itself will refuse to return rows that don't match the current tenant context. Set app.current_tenant at connection time and write policies against it.
Logging context: Every log line should include tenantId. When something breaks at 3 AM, you need to know which tenant is affected without digging through request traces. Structured logging with a tenant field baked into the logger context makes this automatic.
Backups and data deletion: Tenants will churn, and some will invoke GDPR or data deletion rights. If all your data is in shared tables, purging a single tenant means carefully crafted DELETE statements across every table. With schema- or database-per-tenant, it's a DROP SCHEMA or DROP DATABASE. Plan for this from day one.
Rate limiting per tenant: A global rate limiter doesn't cut it. One tenant's batch import shouldn't eat into another tenant's rate budget. Scope your rate limiter by tenantId, not just by IP or user.
Encryption at rest: For sensitive industries, encrypt tenant data at rest with per-tenant keys. This adds complexity to key management, but it means revoking a tenant's access is as simple as destroying their key. No data lingers readable on disk after offboarding.
Final Thoughts
Multi-tenancy isn't a library you install. It's a set of architectural decisions that touch every layer of your stack, from the database to the CDN. The good news is that the patterns are well-understood. The tricky part is applying them with the right level of isolation for your specific market and stage.
Start with shared-everything and a disciplined scoping layer. Add isolation boundaries as your customers demand them. Don't over-engineer for compliance requirements you don't have yet, but make sure your architecture can evolve toward stricter isolation without a rewrite.
I've built multi-tenant systems from restaurant platforms to medical SaaS — each with its own isolation and scaling requirements. The patterns in this post are the ones that held up across all of them.
Related Projects
RestoHub
Restaurants stop losing 30% to Uber Eats — they get their own ordering, menu, website, and loyalty system in one platform. Full Uber Eats-style experience, but the restaurant keeps every dollar.
TakeCare
One nurse now monitors 250 patients remotely — replacing manual phone calls and home visits across Quebec's largest hospitals. Live in Jewish General, CHUM, and Douglas Mental Health Institute.
Danil Ulmashev
Full Stack Developer
Need a senior developer to build something like this for your business?