Engineering

Multi-tenant workspaces in Next.js: what building Moditra taught us

The hard parts of shipping a real B2B multi-tenant SaaS in Next.js 15 — workspace boundaries in the App Router, session isolation, Postgres row-level security, and the mistakes we'd like back.

By Berke ErdoğanJuly 15, 20269 min read
Multi-tenant workspaces in Next.js: what building Moditra taught us

TL;DRMulti-tenant boundaries in Next.js aren't a framework problem — they're a data-access-layer problem. The App Router gives you async server components and middleware, but tenant isolation lives in three places that all have to agree: the session (Better Auth), the query layer (Drizzle + Postgres RLS), and the middleware that scopes the route. Getting any one wrong exposes cross-tenant reads. This is the pattern we ended up with after shipping Moditra.

Multi-tenant SaaS in Next.js sounds like a framework question. It isn't. By the time you're reaching for Next.js, the framework has already made its choices — the App Router, React Server Components, middleware, route handlers. The interesting problem starts one layer down, at the seam between the session and the query.

This is a field guide from shipping Moditra, a B2B SaaS with three role types (brand, atelier admin, modelist), eleven workflow phases, and hard workspace isolation between fashion brands. Every write, every read, every notification has to answer the same question: which workspace is this actor operating in right now, and what are they allowed to see?

The three layers that have to agree

Tenant isolation in a Next.js app lives in three places:

  1. Session — who is the user, which workspace(s) do they belong to, which workspace is active for this request.
  2. Query layer — every SQL query has to be scoped to the active workspace, and the database has to enforce that even if the application layer forgets.
  3. Middleware / routing — URLs like /workspace/[wsSlug]/orders have to match the session's active workspace, otherwise you have a URL guessing attack surface.

If any one of these disagrees with the other two, you have a leak. The usual failure mode isn't dramatic — it's a single API route that forgot the workspace scope, and now user A can list user B's orders by hitting /api/orders?workspace=other-tenant.

Session: Better Auth with a workspaces table

We use Better Auth for the session layer. The core session includes userId and email. On top of that, we add a memberships table that maps users to workspaces with a role:

export const memberships = pgTable("memberships", {
  id: uuid("id").primaryKey().defaultRandom(),
  userId: uuid("user_id").references(() => users.id).notNull(),
  workspaceId: uuid("workspace_id").references(() => workspaces.id).notNull(),
  role: workspaceRoleEnum("role").notNull(),
  createdAt: timestamp("created_at").defaultNow(),
});

The session extension attaches active workspace to every request. Two places do this:

On sign-in, we pick the user's most recently accessed workspace and put its id in the session cookie.

On workspace switch (a specific route: POST /api/workspace/switch), we validate the user is a member of the target workspace, then rewrite the session's active workspace.

The active workspace is a first-class piece of session state. Server components and route handlers read it via a getActiveWorkspace() helper, never by parsing the URL directly.

Query layer: Drizzle + Postgres RLS

Every table that belongs to a workspace has a workspaceId column. Every query that reads or writes those tables includes a where clause scoping to the active workspace. Drizzle makes this ergonomic:

const orders = await db
  .select()
  .from(ordersTable)
  .where(eq(ordersTable.workspaceId, activeWorkspaceId));

But this is where the mistakes happen. It only takes one query somewhere in the codebase that forgot the where clause to leak cross-tenant data.

The defense: Postgres row-level security (RLS) as a second layer. Every workspace-scoped table gets an RLS policy:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY workspace_isolation ON orders
  USING (workspace_id = current_setting('app.active_workspace')::uuid);

Before every query, we set the Postgres session variable app.active_workspace from the Node.js session. If a query somewhere forgets to scope, RLS still filters. It's defense in depth: application correctness plus database enforcement, so a single bug can't leak.

The Drizzle connection pool wraps this — every checkout runs SELECT set_config('app.active_workspace', $1, true) before returning the client to the caller. This is where we hit our first hard bug (see below).

Middleware: workspace-scoped URLs

Next.js middleware runs on every request. Our middleware validates three things:

  1. Authenticated? If not, redirect to /sign-in.
  2. URL matches active workspace? If the URL is /workspace/[wsSlug]/orders and the session's active workspace slug doesn't match [wsSlug], redirect to the correct URL. Prevents URL guessing.
  3. Role check? For admin-only routes, verify the session's role in the active workspace matches.

The App Router middleware runs in the Edge runtime, so we can't use Drizzle directly there — we use a lightweight session lookup via Better Auth's verifySession() and fall through to route handlers for anything requiring a database read.

Three bugs we shipped and had to fix

Bug 1: connection pool state leak. The app.active_workspace Postgres setting was set per connection. In a connection pool with short-lived connections, this worked fine. But we had one long-lived background job that reused a connection and forgot to reset the setting after switching workspaces. A cron running as workspace A pulled data as workspace B for one request before we caught it. Fix: use set_config(..., true) (the true makes it transaction-scoped, not session-scoped) and always run it inside a transaction.

Bug 2: cache key without workspace. React Server Component unstable_cache was memoizing a data fetch by URL params, but the fetch depended on activeWorkspaceId. Two users in different workspaces hitting the same route hit the same cache entry, and the second saw the first's data. Fix: include activeWorkspaceId in the cache tag explicitly.

Bug 3: SSR page in a shared shell. A page component fetched workspace-scoped data at the top and passed it into a client component via props. But the client component was inside a shared layout, and Next.js rendered the layout once across workspaces. The workspace-scoped props leaked between users on the same server instance. Fix: don't share layouts across workspace-scoped routes; put workspace-scoped state in route-level components that don't cross workspace boundaries.

The pattern we ended up with

Here's the mental model we now use for every multi-tenant Next.js project:

  • Session is source of truth for the active workspace. Not URL, not props, not cookies read directly. Always getActiveWorkspace().
  • Query layer scopes everything via where workspaceId = ?. Assume you'll forget once; the RLS layer catches it.
  • RLS is defense in depth, not the only defense. If RLS is your only isolation and it fails, you have zero layers left.
  • Middleware enforces URL/session parity. If the URL says workspace X and the session says workspace Y, redirect. Don't try to reconcile silently.
  • Cache keys include workspace ID. Every memoized function that touches workspace data has to include the workspace in the key. This will bite you if you forget.

When you don't need this

If you're building a B2C SaaS where "workspace" means "one user's data," you don't need most of this. userId on every row is enough. The complexity above starts to matter when workspaces contain multiple users, when users belong to multiple workspaces, or when the workspace has its own settings/branding/data model distinct from individual users.

Moditra had all three: multiple users per brand workspace, some users belonging to multiple ateliers, and per-workspace configurations for things like default currencies and label templates.

The takeaway

Multi-tenant Next.js isn't a framework problem you Google an answer for. It's a data-access-layer discipline that has to be enforced in three places simultaneously — session, query, middleware — and cross-checked by the database itself. The tooling exists (Better Auth, Drizzle, Postgres RLS, App Router middleware). The discipline of using them all together, consistently, is what turns a demo into production-safe SaaS.


Related work: