Building a SaaS platform is one of the most rewarding — and complex — challenges for a backend developer. You are not just building an application; you are building a platform that hosts many applications, each isolated, billed, and governed independently. Having built and shipped ROI Spectrum, a production multi-tenant SaaS platform, I want to share the architecture decisions that made it work.
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() });
}Key Takeaways
- Start with row-level tenant isolation — it scales to thousands of tenants without infrastructure overhead.
- Use middleware to enforce tenant scoping on every database query.
- Integrate Stripe via webhooks, not polling — it is reliable, real-time, and idempotent.
- Gate features by plan at the middleware layer so business logic stays clean.
- Audit everything — future-you will thank past-you during the first compliance review.
Building SaaS is a marathon, not a sprint. Get the architecture right, and scaling becomes an operations problem instead of a rewrite.
