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.

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.
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.