Building a SaaS platform means building a platform that hosts many smaller applications inside it — each isolated, billed, and governed on its own. This is the architecture behind ROI Spectrum, a multi-tenant SaaS platform I built and shipped, and what held up in production.
Choosing a Tenant Isolation Strategy
The first decision: how do you separate tenant data?
| Strategy | Isolation Level | Cost | Complexity |
|---|---|---|---|
| Database-per-tenant | Complete | High | High |
| Schema-per-tenant | Strong | Medium | Medium |
| Row-level (shared tables) | Moderate | Low | Low |
For most B2B SaaS products handling fewer than 10,000 tenants, row-level isolation with a tenantId column on every table provides the best balance of cost and simplicity. I use this strategy with strict middleware enforcement.
Tenant Context Middleware
Every request must carry a tenant identifier. I extract it from a JWT claim or a custom header and attach it to the request context:
import { Request, Response, NextFunction } from 'express';
export function tenantContext(req: Request, res: Response, next: NextFunction) {
const tenantId = req.user?.tenantId || req.headers['x-tenant-id'];
if (!tenantId) {
return res.status(400).json({ error: 'Tenant context is required' });
}
req.tenantId = tenantId as string;
next();
}
Every database query then scopes to the tenant:
const orders = await Order.find({ tenantId: req.tenantId, status: 'active' });
A Mongoose middleware can enforce this automatically to prevent accidental cross-tenant data leaks:
OrderSchema.pre(/^find/, function (next) {
const tenantId = this.getOptions().tenantId;
if (tenantId) this.where({ tenantId });
next();
});
Subscription Billing with Stripe
Stripe is the industry standard for SaaS billing. Here is the integration flow:
1. Creating a Checkout Session
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
async function createCheckoutSession(tenantId: string, priceId: string) {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
metadata: { tenantId },
success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/billing/cancel`,
});
return session.url;
}
2. Handling Webhooks
Stripe sends events asynchronously. You must verify the webhook signature and handle critical events:
app.post('/api/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err) {
return res.status(400).send('Webhook signature verification failed');
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
await activateTenant(session.metadata!.tenantId!, session.subscription as string);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object as Stripe.Invoice;
await suspendTenant(invoice.metadata!.tenantId!);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object as Stripe.Subscription;
await deactivateTenant(sub.metadata!.tenantId!);
break;
}
}
res.json({ received: true });
});
3. Subscription Lifecycle Management
async function activateTenant(tenantId: string, subscriptionId: string) {
await Tenant.findByIdAndUpdate(tenantId, {
subscriptionId,
subscriptionStatus: 'active',
plan: 'pro',
});
await redis.del(`tenant:settings:${tenantId}`); // invalidate cache
}
Feature Gating by Plan
Different plans unlock different features. I store plan capabilities in a config and check at the middleware level:
const PLAN_LIMITS = {
free: { maxUsers: 3, maxProjects: 5, apiCalls: 1000 },
pro: { maxUsers: 25, maxProjects: 50, apiCalls: 50000 },
enterprise: { maxUsers: Infinity, maxProjects: Infinity, apiCalls: Infinity },
};
export function requirePlan(minPlan: 'free' | 'pro' | 'enterprise') {
return async (req: Request, res: Response, next: NextFunction) => {
const tenant = await getTenantSettings(req.tenantId);
const planOrder = ['free', 'pro', 'enterprise'];
if (planOrder.indexOf(tenant.plan) < planOrder.indexOf(minPlan)) {
return res.status(403).json({ error: 'Upgrade your plan to access this feature' });
}
next();
};
}
Audit Logging
Every mutation should be logged for compliance and debugging:
async function logAudit(tenantId: string, action: string, userId: string, details: object) {
await AuditLog.create({ tenantId, action, userId, details, timestamp: new Date() });
}
Wrapping Up
Row-level isolation plus middleware that enforces it on every query gets you further than it sounds like it should. The part that actually took the longest to get right wasn't the isolation — it was the Stripe webhook handling, and making sure a failed payment couldn't leave a tenant half-active.
