Building a multi-tenant SaaS on the MERN stack: lessons from WorkNavo
Most tutorials show you single-tenant auth: one user, one account, one set of data. Real B2B products rarely work that way. A freelancer using an invoicing tool might run their own solo workspace and also be a contractor inside a client's organization. Get the data model wrong early and every feature after auth becomes a migration.
This is the shape I settled on while building WorkNavo, a client-operations platform on the MERN stack (MongoDB, Express, React, Node.js). Here's the reasoning, not just the schema.
Organization-scoped documents, not user-scoped ones
The instinct when you start is to attach data directly to a user: Project.owner = userId. That works until a user needs to belong to two organizations, or an admin needs to see everything without impersonating anyone. Instead, every domain document carries an organizationId, and membership is its own collection:
const membershipSchema = new Schema({
user: { type: Schema.Types.ObjectId, ref: "User", required: true },
organization: { type: Schema.Types.ObjectId, ref: "Organization", required: true },
role: {
type: String,
enum: ["admin", "project_manager", "finance", "member", "viewer"],
required: true,
},
status: { type: String, enum: ["active", "suspended"], default: "active" },
});
membershipSchema.index({ user: 1, organization: 1 }, { unique: true });
A user's session doesn't carry a single role — it carries a list of memberships. The frontend picks an "active organization" (persisted in localStorage, restored on load), and every API request is scoped to that org via a header or route param, never inferred from the JWT alone.
Scoping at the query layer, not the route layer
The dangerous version of multi-tenancy is checking req.user.organizationId === resource.organizationId inside individual route handlers. It works until someone forgets it in the twentieth new endpoint. Push the check into a middleware that resolves and attaches the active membership before any handler runs:
async function resolveMembership(req: Request, res: Response, next: NextFunction) {
const orgId = req.headers["x-organization-id"];
const membership = await Membership.findOne({
user: req.user.id,
organization: orgId,
status: "active",
});
if (!membership) return res.status(403).json({ error: "No access to this organization" });
req.membership = membership; // role + org available to every downstream handler
next();
}
Every subsequent query filters by req.membership.organization, not by anything the client sends unchecked. Role checks (requireRole(["admin", "finance"])) compose on top of the same middleware chain.
Role visibility isn't just allow/deny
The harder problem isn't blocking access — it's partial visibility. A Project Manager should see a project's timeline and team, but not what a client is being billed. A Member should see their own logged hours, not the whole team's. That's not a permissions boolean; it's a projection problem.
I handle it by defining role-aware serializers rather than branching inside components:
function serializeProject(project: ProjectDoc, role: Role) {
const base = { id: project.id, name: project.name, status: project.status };
if (role === "member") return base;
return { ...base, budget: project.budget, billedTotal: project.billedTotal };
}
The API never sends financial fields to a role that shouldn't see them — it's not a frontend if statement hiding a div, it's data that never leaves the server. That distinction matters the first time someone opens dev tools.
Suspension without deletion
Freelance ops tools need to suspend a contractor's access immediately — a client relationship ends, a dispute happens — without deleting their historical time logs and invoices. Because access flows through Membership.status, suspension is a single field flip that takes effect on the next request, while every past record they created stays intact and attributed correctly. Deleting the user, by contrast, would silently orphan every report that referenced them.
What I'd tell someone starting this today
Model membership as its own collection from day one, even if you think you'll only ever have one organization per user — retrofitting it later means touching every query in the codebase. Push authorization into middleware once, not into every handler. And treat "what fields does this role see" as a serialization concern, not a UI concern. None of this is exotic — it's the same multi-tenancy pattern most B2B SaaS products converge on — but it's much cheaper to build it this way from the start than to bolt it on after your first enterprise client asks for role-based access.