Engineering

How we built B2B SaaS authorization without multi-tenancy

Every B2B SaaS template tells you to pick multi-tenancy first. Moditra didn't. Here is what we built instead — role-based access, a service-layer authorization boundary, state-machine workflows, SSE for real-time — and why it has held up.

By Berke ErdoğanMay 27, 20269 min read
How we built B2B SaaS authorization without multi-tenancy

TL;DRMost B2B SaaS templates start with multi-tenancy. Moditra skipped it. This post walks through what role-based access + service-layer authorization look like in production — the trade-off we accepted, the architectural choices it forced, and the point at which this shortcut stops working.

Every B2B SaaS tutorial starts the same way: "First, decide your multi-tenancy strategy. Subdomain per tenant or path-based? Row-level security or schema-per-tenant?" The choice is treated as load-bearing — the decision that constrains everything else downstream.

We didn't make it.

Moditra is a B2B platform for modelist ateliers — the studios that translate fashion designs into production-ready patterns. We shipped the first version with three user roles, one PostgreSQL database, and zero workspaces. No subdomains. No tenant_id column. No multi-tenant middleware. This is the architectural reasoning, the trade-off we accepted, and what the codebase actually looks like underneath.

The conventional pitch — and when it fits

Workspace multi-tenancy makes sense when:

  • Customers expect data isolation as part of the contract (B2B enterprise sales).
  • Tenants are organizations with hierarchical members.
  • You'll eventually need per-tenant configuration, branding or billing.
  • You're building horizontally — same product, many companies.

When those don't hold, the cost is real and the benefit is theoretical. Every query carries a tenant filter. Every UI route knows the tenant. Every test seeds tenant fixtures. You pay all of that without earning the upside.

Moditra's reality: each user is either a customer placing modelist orders, a modelist working on those orders, or an admin running the platform. Customers don't form organizations. Modelists don't have teams under them. There is no hierarchical "workspace" to model — just individuals with roles.

Roles, not workspaces

The user schema is small. The decisive field is a three-value role enum: the buyer placing orders, the operator working on them, and the platform administrator. That single field gates everything. Buyers see orders they placed. Operators see orders assigned to them. Admins see the whole platform. Resource ownership is enforced at query time — a buyer-id filter for the buyer's queries, an assignee-id filter for the operator's queries — but the boundary is the user's row, not a workspace's row.

This wouldn't scale if customers managed teams. They don't. If they ever do, the migration is bounded: add an organizationId to users, add organization-aware queries to the orders service, add a workspace switcher to the UI. We'd rather pay that cost when there's proof it's worth paying than build it speculatively.

The service layer is the authorization boundary

This is where Moditra's architecture gets opinionated. Every piece of business logic lives in a single internal package: @moditra/services. Panel server actions and Fastify routes are deliberately thin — under twenty lines — because their only job is to receive a request, extract the actor's user ID, and call the service.

The service does the authorization check. Every function in @moditra/services takes actorUserId as its first argument. Listing orders runs role and ownership checks inside the service before touching the database. There is no scenario where business logic runs without the actor identity threaded through it.

The point of this discipline isn't elegance. It's that authorization holes are the most expensive class of B2B bug. Putting the check in the route is "remember to check every time." Putting the check in the service is "the check is the function." A new route handler can't accidentally skip enforcement because the service it calls already enforces it.

State machines for stateful workflows

Orders move through six statuses — draft, under review, in progress, in approval, completed, and cancelled. Each transition is gated by an allowed-transitions map per role.

Buyers can advance an order one step but can't unilaterally complete it. Operators take over once an order moves into review and progress it forward, but can't unilaterally cancel. Admins can override any transition. The map is the authority — there's no if (status === 'X' && user.role === 'Y') sprinkled through the codebase.

This is the same pattern as XState or the Rails state_machine gem, hand-written in a couple dozen lines of TypeScript. The win isn't the tool — it's making the workflow rules an artifact, queryable from one file, rather than behavior emergent from a dozen conditional checks across the app.

SSE, not WebSocket

Moditra is real-time. Orders update, chat messages arrive, notifications fire. We don't use WebSockets. A small set of Server-Sent Events endpoints — one stream per real-time domain (order updates, chat, notifications) — pushes events to clients over plain HTTP long-lived requests, with a 25-second heartbeat to keep proxies from timing out idle connections.

WebSocket is the default answer for SaaS real-time. SSE is the right answer when:

  • Traffic is server → client (messages get sent via normal POST).
  • You want to inherit your existing HTTP infrastructure: auth cookies, CDN, proxies, load balancer all work without special handling.
  • You want to debug with curl.

We chose SSE because it solved the problem without forcing a WebSocket- capable proxy layer in front of the Node server. The 25-second heartbeat is the only operational subtlety you have to remember. Total real-time infrastructure: under 200 lines, across three routes.

Skeletons are CSS, not state

Loading states never reach React state. A single CSS keyframe — a 1.4 second linear shimmer loop — drives every skeleton block in the app. The components are stateless, named like <SkeletonKpiStrip /> or <SkeletonFormCard rows={3} />. They render server-side, ship zero JavaScript for the loading state, and animate via CSS without rendering churn.

This isn't a performance win measured in microseconds. It's a category change: we never write useState('loading') because the Suspense boundary above the component renders the skeleton, then streamed content replaces it. The loading state isn't a state the app manages — it's a layout the app falls through.

Cross-environment cookie discipline

Less interesting but worth flagging because it cost us a week of confusion: production and staging share a root domain, so the secure session cookie collides between environments by default. We solved it with an env-driven cookie name prefix — staging cookies carry a -staging suffix on the name — and middleware probes both forms so local dev (no prefix) still works.

Every B2B SaaS that runs staging on staging.app.example.com and prod on app.example.com eventually hits this. The fix is mechanical. The cost of finding it the first time is two days. Worth knowing about.

What we'd do differently

If we built Moditra again knowing what we know now:

  • Role-based + service-layer authorization: keep this. It's correct for the problem.
  • SSE: keep. Slight unfamiliarity, worth it.
  • One PostgreSQL: keep. The pressure to introduce caching layers is constant. Resist until you have proof you need one.

When this stops working

This architecture works because the role set is fixed and data ownership is individual. The moment any of that changes — customer ateliers grow teams, white-label tenants appear, regulatory data isolation becomes a contract — the role-based shortcut breaks and we'd move to proper workspace multi-tenancy.

Until that moment, the simplest code that does the job is the right code. Workspace multi-tenancy is the default B2B SaaS architecture. It is not always the correct one.

Related caseModitra — B2B Modelist Atelier SaaSModitra · SaaS Platform / UI / UX · 2026Read the case study →

We're a small product studio based in Istanbul, working globally. If you're building a B2B platform and the multi-tenancy default doesn't fit your reality, that's a conversation we like — contact us or read more about how we work.