01 / What Multi-Tenancy Actually Means
Multi-tenancy means one running instance of your application serves multiple customers — tenants — with their data completely isolated from each other. Tenant A cannot see Tenant B's data, cannot affect Tenant B's performance, and cannot access Tenant B's configuration.
For Faigen, tenants are businesses. A restaurant using Faigen and a coir mat shop using Faigen are two separate tenants on the same Node.js process, the same MongoDB instance, and the same Vercel deployment. They share infrastructure but are completely isolated at the data layer.
There are three common approaches to multi-tenancy at the database layer. Understanding the tradeoffs before choosing one saves you from a painful migration later.
For Faigen's scale and budget — a bootstrapped product serving Kerala SMBs — shared collections with companyId scoping was the only sensible choice. Separate databases per tenant would have meant paying for 20+ database connections from day one.
02 / The Schema Design
Every model that contains tenant-specific data has a requiredcompanyId field. This is the foreign key that links every document to exactly one tenant.
The compound index on companyId + customerPhone + platformmeans that looking up a conversation for an incoming WhatsApp message hits the index directly — no collection scan, regardless of how many tenants are on the platform.
03 / Tenant Resolution at the Webhook Layer
Every incoming WhatsApp message contains a phone_number_idin the webhook metadata. This is the unique identifier for the WhatsApp Business number that received the message — and it maps directly to one company in our database.
Tenant resolution happens at the very top of the message handler — before any business logic runs. If no company is found, the message is silently dropped. If found, all subsequent code operates within that tenant's context.
04 / Tenant Resolution in Next.js API Routes
The admin dashboard is a Next.js app. Every API route that serves dashboard data must resolve the authenticated user's company and scope all queries to that company. No exceptions.
I built a reusable middleware function that extracts the company from the session JWT and returns it — or throws a 401 if the session is invalid.
The key discipline: companyId: company._id appears on every MongoDB query in every API route. If it is ever missing — if a developer writes Conversation.find({ isActive: true })without the companyId scope — that query returns conversations from all tenants. This is a data leak.
To guard against this, I added an ESLint rule that flags any Mongoose .find() or .findOne() call that does not include companyId in the query object. Not perfect — it can be bypassed — but it catches the common case.
05 / Per-Tenant Feature Flags
Not all tenants have the same features enabled. A free-tier tenant might not have broadcast campaigns or Instagram automation. A premium tenant might have a higher message limit and custom AI config.
Feature flags live on the Company document — a flat object of booleans checked at runtime before any feature runs.
Enabling a new feature for a tenant is a single database update. No deployment, no config change, no restart. The next message from that tenant's customers picks up the new feature flag automatically.
06 / Per-Tenant AI Configuration
Each company can have a completely different AI setup — different provider (Gemini, OpenAI, Groq), different model, different system prompt, different temperature, and their own API key.
The getAIResponse function reads the company's config and dispatches to the correct provider. Adding a new AI provider means adding one case to the dispatcher — zero changes to the tenant data model.
07 / The One Rule That Prevents Data Leaks
After building this, the single most important rule I can offer for anyone building a companyId-scoped multi-tenant system is this:
Never write a query without companyId. Not once, not as a quick fix, not in a utility function that "only runs in admin context." The discipline must be absolute or data isolation is not isolation — it is hope.
Every query in the codebase that touches tenant data follows the same pattern:
Even for deleteOne. Even for updateOne. Even when you "know" the ID belongs to the right company. The companyId scope is your defence against a bug that could expose one tenant's data to another. Write it every time.
08 / What I Would Do Differently
Add a Mongoose plugin for automatic companyId injection
Instead of manually adding companyId to every query, a Mongoose plugin can intercept every find/update/delete operation and automatically inject the companyId from a request context. This makes the scoping invisible and impossible to forget — but requires careful setup to not break admin queries that legitimately need cross-tenant access.
Build tenant isolation tests from day one
A test that creates two tenants, writes data for each, and verifies that querying as tenant A never returns tenant B's data. This test should run in CI on every push. I built this retrospectively — it should have been the first test written.
Rate limit at the tenant level, not just the IP level
IP rate limiting is not enough for a multi-tenant system. A single tenant can send thousands of messages from different IPs. Per-tenant rate limiting — tracked in Redis by companyId — protects the platform from one tenant degrading performance for all others.
Next Article
Express.js Hospital Routing →