Backend EngineeringNovember 20257 min read

Custom MongoDB Multi-Tenancy in Next.js for B2B Applications.

Why third-party multi-tenancy libraries were the wrong choice for Faigen, and how I built companyId-scoped data isolation from scratch — in a way that scales to hundreds of tenants without touching a single line of business logic.

Author

Faize Muhammed Basheer

Product

Faigen AI — Multi-tenant SaaS

Stack

Node.js / Next.js / MongoDB / Mongoose

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.

Approach 1 — Separate database per tenant ✅ Maximum isolation ✅ Easy to offboard a tenant (drop database) ❌ Expensive at scale — connection pools multiply ❌ Schema migrations run N times (once per tenant) ❌ Cross-tenant analytics is painful Approach 2 — Separate collection per tenant ✅ Good isolation ❌ MongoDB collection count grows unbounded ❌ Indexes must be created per collection ❌ No benefit over approach 1 at scale Approach 3 — Shared collections, companyId field (chosen) ✅ Single schema to maintain ✅ Single connection pool ✅ Scales to hundreds of tenants transparently ✅ Cross-tenant analytics trivial (filter by companyId) ⚠️ Requires strict discipline — every query must scope to companyId ⚠️ Requires indexes on companyId for performance

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.

// Company model — one document per tenant const companySchema = new mongoose.Schema({ name: { type: String, required: true }, ownerPhone: { type: String, required: true }, whatsappPhoneNumberId: { type: String, default: null }, systemPrompt: { type: String, default: '...' }, welcomeMessage: { type: String, default: '...' }, aiProvider: { type: String, enum: ['gemini', 'openai', 'groq'] }, aiModel: { type: String }, aiApiKey: { type: String, select: false }, // never returned by default features: { type: featuresSchema }, aiContext: { type: aiContextSchema }, broadcastCredits: { type: Number, default: 0 }, active: { type: Boolean, default: true }, }, { timestamps: true }) // Conversation model — scoped to company const conversationSchema = new mongoose.Schema({ companyId: { type: mongoose.Schema.Types.ObjectId, ref: 'Company', required: true }, customerPhone: { type: String, required: true }, platform: { type: String, enum: ['whatsapp', 'instagram'] }, messages: [messageSchema], isActive: { type: Boolean, default: true }, lastMessageAt: { type: Date }, }, { timestamps: true }) // Critical — compound index for tenant-scoped queries conversationSchema.index({ companyId: 1, customerPhone: 1, platform: 1 }) conversationSchema.index({ companyId: 1, isActive: 1, lastMessageAt: -1 }) // Order model — scoped to company const orderSchema = new mongoose.Schema({ companyId: { type: mongoose.Schema.Types.ObjectId, ref: 'Company', required: true }, customerPhone: { type: String, required: true }, product: { type: String, required: true }, quantity: { type: Number, required: true }, status: { type: String, enum: ['pending', 'confirmed', 'shipped', 'delivered'] }, }, { timestamps: true }) orderSchema.index({ companyId: 1, status: 1, createdAt: -1 })

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.

const processMessage = async (message, value) => { // Step 1 — resolve tenant from incoming webhook const phoneNumberId = value.metadata.phone_number_id const company = await Company.findOne({ whatsappPhoneNumberId: phoneNumberId, active: true }).select('+aiApiKey') // aiApiKey excluded by default — opt in explicitly // Unknown number — not our tenant if (!company) { console.log('No company for phoneNumberId:', phoneNumberId) return } // Suspended tenant — service unavailable if (company.billingStatus === 'suspended') { await sendTextMessage(phoneNumberId, message.from, 'Service temporarily unavailable. Please contact the business directly.') return } // Feature flag check — is WhatsApp enabled for this tenant? if (!company.features?.whatsapp) { console.log('WhatsApp disabled for:', company.name) return } // From here — all operations scoped to company._id const conversation = await Conversation.findOne({ companyId: company._id, // always scoped customerPhone: message.from, platform: 'whatsapp' }) // AI runs with this tenant's config const response = await getAIResponse( company, // contains systemPrompt, aiModel, aiApiKey, aiContext conversation.messages, customerMessage ) }

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.

// lib/getAuthCompany.js import { getServerSession } from 'next-auth' import { authOptions } from './auth' import Company from '@/models/Company' import dbConnect from './db' export async function getAuthCompany(req, res) { const session = await getServerSession(req, res, authOptions) if (!session?.user?.companyId) { return { error: 'Unauthorized', status: 401 } } await dbConnect() const company = await Company.findOne({ _id: session.user.companyId, active: true }) if (!company) { return { error: 'Company not found', status: 404 } } return { company } } // pages/api/conversations/index.js export default async function handler(req, res) { const { company, error, status } = await getAuthCompany(req, res) if (error) return res.status(status).json({ error }) // All queries automatically scoped to this tenant const conversations = await Conversation.find({ companyId: company._id, // scope enforced here isActive: true }) .sort({ lastMessageAt: -1 }) .limit(50) res.json({ success: true, data: conversations }) }

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.

// Company schema — features sub-document features: { whatsapp: { type: Boolean, default: true }, instagram: { type: Boolean, default: false }, orderCollection: { type: Boolean, default: true }, productCatalog: { type: Boolean, default: true }, marketing: { type: Boolean, default: false }, otp: { type: Boolean, default: false }, interactiveMessages: { type: Boolean, default: true }, analytics: { type: Boolean, default: true }, imageSupport: { type: Boolean, default: false }, } // Usage in webhook handler — check before running feature if (company.features?.interactiveMessages && company.aiContext?.mainMenuButtons?.length > 0) { await sendButtonMessage(phoneNumberId, customerPhone, welcomeText, company.aiContext.mainMenuButtons) } else { await sendTextMessage(phoneNumberId, customerPhone, welcomeText) } // Enabling a feature for a tenant — one DB update await Company.findByIdAndUpdate(companyId, { $set: { 'features.instagram': true } })

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.

// services/ai.js export const getAIResponse = async (company, messages, userMessage) => { const provider = company.aiProvider || 'groq' const model = company.aiModel const apiKey = company.aiApiKey // select: false — must be explicitly selected const prompt = buildSystemPrompt(company) // merges systemPrompt + aiContext try { switch (provider) { case 'gemini': return await callGemini(model, apiKey, prompt, messages, userMessage) case 'openai': return await callOpenAI(model, apiKey, prompt, messages, userMessage) case 'groq': default: return await callGroq(model, apiKey, prompt, messages, userMessage) } } catch (err) { console.error('AI error [' + provider + '/' + model + ']:', err.message) // Fallback to Groq default if primary provider fails if (provider !== 'groq') { console.warn('Falling back to Groq default') return await callGroq('llama-3.3-70b-versatile', null, prompt, messages, userMessage) } throw err } }

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:

// The pattern — always, everywhere, no exceptions const data = await Model.find({ companyId: company._id, ...otherFilters }) const item = await Model.findOne({ companyId: company._id, _id: itemId }) await Model.updateOne({ companyId: company._id, _id: itemId }, update) await Model.deleteOne({ companyId: company._id, _id: itemId })

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

01

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.

02

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.

03

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.