Guides
Build guide

Multi-tenant Remix: put the workspace in the URL, and check membership before any loader runs

Multi-tenant workspaces in Remix and React Router v7 or v8: the workspace lives in the URL, and one membership check runs before any loader can fetch.

Dharmendra Jagodana8 min read

In short

A multi-tenant Remix or React Router app should keep the active workspace in the URL as /w/:workspaceId, not a cookie, so each tab stays in its own workspace. Check membership before any loader runs: one middleware on v8 or flagged v7, or one helper in every loader, because loaders run in parallel.

Here is the check almost every multi-tenant Remix app starts with. A layout route at /w/:workspaceId loads the workspace, throws if the user is not a member, and every page for that workspace nests under it. It reads like a guard. For years it was not one.

Remix, and React Router before middleware, ran the loaders of every matched route in parallel. The Remix team put it bluntly when they shipped middleware: people assumed "a redirect from a parent loader would prevent the child loader from running - but this was also not the case." Your projects loader has already queried the database and returned rows for a workspace the user was never in, while the layout is still on its way to throwing.

Two decisions follow, and the second matters more. The workspace lives in the URL. And the membership check runs before any loader: in middleware if your React Router has it, in one helper that every loader and action calls if it does not.

Before you start

A React Router v7 or v8 app in framework mode (or Remix v2) with sign-in working through the React Router setup guide. A requireBuildBase(request) helper that returns a session-bound client, as in usage-based billing in React Router. Your organization in Platform mode: new organizations start in Personal mode, one workspace per user with the switcher hidden, and there is nothing to switch between.

A cookie holds one "current" workspace for the whole browser. A URL holds one per tab, and a link pasted into Slack opens the right workspace for whoever clicks it.

Keeping currentWorkspaceId in the cookie session is tempting anyway: every loader can read it, and URLs stay short. Then someone opens a second tab and switches workspace there, which rewrites the cookie. Back in the first tab nothing moves. At the next revalidation, the page they were reading quietly shows the other tenant's data under the same URL. Worse, a form posted from that first tab lands in whichever workspace the cookie names now.

(A subdomain per customer, routed by host header, is a different problem: custom domains, not one user in several teams. This guide does not cover it.)

The cookie keeps one job: remembering which workspace to open by default. The /w redirect further down uses it that way and nowhere else.

One function asks the server whether this user is a member

Put the membership check in one function, and make it the only way a route gets a workspace:

// app/utils/workspace.server.ts
import { buildbaseFor } from '#app/utils/buildbase.server.ts';
import { data } from 'react-router';

type BuildBaseClient = ReturnType<typeof buildbaseFor>;

export type Workspace = {
  id: string;
  role: string | null;
  permissions: string[];
};

export async function resolveWorkspace(
  bb: BuildBaseClient,
  workspaceId: string
): Promise<Workspace> {
  try {
    const me = await bb.workspace.permissions(workspaceId);
    return { id: workspaceId, role: me.role, permissions: me.permissions };
  } catch (err) {
    const status = (err as { status?: number }).status;
    if (status === 400 || status === 403 || status === 404) {
      throw data('Workspace not found', { status: 404 });
    }
    throw err;
  }
}

bb.workspace.permissions() asks the server what the session's user may do in that workspace, and gets back { role, isOwner, permissions }. The server resolves it from the workspace's own members, so a workspace ID typed into the address bar proves nothing on its own.

Two answers come back as errors. A string that is not a valid ID gets a 400 (Invalid workspace ID.). A workspace the user does not belong to gets a 403 (You are not a member of this workspace.), and so does one that does not exist at all. That second part is deliberate. The same answer for both means nobody can probe for which workspace IDs are real.

The status sits on the thrown error but not in the SDK's type definitions, hence the cast.

Note what the function does not do: redirect to /login, or answer 401. We learned that one on our own server. Its membership guard used to answer 401, and our React SDK treats every 401 from it as a dead session. As the comment in that guard now says, "A 401 here signed people out for opening a workspace they had lost access to." A user removed from one workspace is still signed in, and still a member of the others. Send them a 404 page with a link home.

With middleware (v8, or v7.9+ with the flag): one check on the layout

Middleware is the framework's own answer to the parallel-loader problem. It "runs sequentially on the server before and after document and data requests," and on the leaf route next is what runs the loaders and actions. Throw before next and no loader below runs.

It is always on in React Router v8. On v7.9 or later, turn it on first:

// react-router.config.ts (v7.9+ only; remove the flag on v8, whose build
// fails while it is still set)
import type { Config } from '@react-router/dev/config';

export default {
  future: { v8_middleware: true },
} satisfies Config;

Then the layout route checks once and hands the result down:

// app/routes/w.$workspaceId.tsx
import { requireBuildBase } from '#app/utils/buildbase.server.ts';
import {
  resolveWorkspace,
  type Workspace,
} from '#app/utils/workspace.server.ts';
import { createContext, Outlet } from 'react-router';

import type { Route } from './+types/w.$workspaceId';

export const workspaceContext = createContext<Workspace>();

export const middleware: Route.MiddlewareFunction[] = [
  async ({ request, params, context }) => {
    const bb = await requireBuildBase(request);
    context.set(
      workspaceContext,
      await resolveWorkspace(bb, params.workspaceId)
    );
  },
];

export default function WorkspaceLayout() {
  return <Outlet />;
}

A child route reads the workspace from context instead of from params:

// app/routes/w.$workspaceId.projects.tsx
import { prisma } from '#app/utils/db.server.ts';

import type { Route } from './+types/w.$workspaceId.projects';
import { workspaceContext } from './w.$workspaceId';

export async function loader({ context }: Route.LoaderArgs) {
  const workspace = context.get(workspaceContext);
  const projects = await prisma.project.findMany({
    where: { workspaceId: workspace.id },
  });
  return {
    projects,
    canEdit: workspace.permissions.includes('app:projects:create'),
  };
}

That context.get is the whole point. A child that reads the workspace this way cannot run without the middleware having set it, and the middleware cannot set it without the server having said yes. A child that reads params.workspaceId directly works too, right up until someone mounts it somewhere else.

Open /w/<an ID you are not in>/projects and you should get the 404 page, with no query in your database log.

Without middleware: the same check, in every loader and action

On Remix v2, or React Router v7 before 7.9 or without the flag, there is no stable place that runs first. So the check goes where the data is:

// app/utils/workspace.server.ts (continued)
import { requireBuildBase } from '#app/utils/buildbase.server.ts';

export async function requireWorkspace(
  request: Request,
  params: { workspaceId?: string }
) {
  if (!params.workspaceId) {
    throw data('Workspace not found', { status: 404 });
  }
  const bb = await requireBuildBase(request);
  return resolveWorkspace(bb, params.workspaceId);
}
// app/routes/w.$workspaceId.projects.tsx
export async function loader({ request, params }: Route.LoaderArgs) {
  const workspace = await requireWorkspace(request, params);
  const projects = await prisma.project.findMany({
    where: { workspaceId: workspace.id },
  });
  return { projects };
}

Every loader. Every action, including the ones that only delete something, because an action is a POST to the same URL and nothing stops anyone sending it. The layout loader can call it too, for the name in the header, but it guards nothing below it.

That is more repetition than the middleware version, and it is the honest case for upgrading. A helper you have to remember is a helper someone forgets in the fortieth route. In review, grep for loader and action exports under routes/w.$workspaceId.* and check every one calls it.

Either way, every request now asks the server once, and data requests after navigation count. That round trip is the price of the server deciding. Do not cache the answer across requests: a membership revoked a minute ago has to stop working now.

Key takeaway

The URL says which workspace a tab wants. Only the server can say whether this user may have it, and that answer has to arrive before any loader runs, which a parent loader cannot promise.

Where /w sends people

Someone who types your domain has no workspace in their URL. An index route picks one:

// app/routes/w._index.tsx
import { requireBuildBase } from '#app/utils/buildbase.server.ts';
import { redirect } from 'react-router';

import type { Route } from './+types/w._index';

export async function loader({ request }: Route.LoaderArgs) {
  const bb = await requireBuildBase(request);
  const workspaces = await bb.workspace.list();
  const lastUsed = await getLastWorkspaceId(request); // your cookie, if any
  const target = workspaces.find((w) => w._id === lastUsed) ?? workspaces[0];
  if (!target) throw redirect('/welcome');
  throw redirect(`/w/${target._id}`);
}

workspace.list() returns every workspace the user belongs to, in one call. Use _id in the URL. The workspace object also has a field called workspaceId, which is a different value, and the membership check will answer 400 for it.

Here the cookie earns its keep. It remembers the last workspace so /w can reopen it, and it is only ever a suggestion: the redirect still lands on a URL that goes through the membership check, and a stale cookie pointing at a workspace the user left just falls through to the first one.

With Auto-Create First Workspace on (it is by default, under the workspace settings' Advanced Overrides), list() creates a workspace for a user who has none, so /welcome fires only when you have turned that off, or when a workspace cap or your plan's limit refuses the auto-create: list() then answers with an empty list rather than an error.

The switcher is a list of links. Each one goes to /w/${w._id}, and each tab can sit in a different workspace without either one noticing.

Let members see, and only some of them act

Check the permission in the action, not the button. You already have it: the membership check returns more than yes or no. role is admin, editor or viewer by default (stored lowercase, so compare against those), or a custom role, and permissions is the resolved list, platform keys and your organization's own.

// app/routes/w.$workspaceId.projects.tsx (v8 version)
import { data } from 'react-router';

export async function action({ context, request }: Route.ActionArgs) {
  const workspace = context.get(workspaceContext);
  if (!workspace.permissions.includes('app:projects:create')) {
    throw data('Your role cannot create projects.', { status: 403 });
  }
  // ...create the project in workspace.id
}

A viewer can open every page in the workspace and still gets a 403 from this action. That is working as intended. Hiding the button is a courtesy; the action is the rule.

None of this costs a request, since the permissions arrived with the membership check. If you need the answer somewhere the middleware did not run, bb.workspace.can(workspaceId, 'app:projects:create') returns it as a boolean, at the price of another round trip.

Five symptoms, and the cause behind each

Opening a workspace you were removed from signs you out. Something maps the membership failure to 401, or redirects to /login. Answer 404 (or 403). The session is fine.

A child route returns data the user should not see, then the error page appears. The check lives only in the parent loader. Move it into middleware, or on older versions call requireWorkspace in the child loader itself.

Two tabs keep switching each other's workspace. The active workspace lives in a cookie. Put it in the URL and demote the cookie to the default for /w.

Every workspace page is a 404, even your own. The URL carries the workspace's workspaceId field rather than its _id. The server answers 400 Invalid workspace ID., and resolveWorkspace turns that into the 404.

A new teammate cannot see the workspace at all. Membership, not routing. Start with an invited member who cannot see the workspace.

Serving the same workspaces from an Express, Hono or Fastify API as well? There the check is one middleware, mounted where no handler can miss it: multi-tenant workspaces in Express.

Why workspaces belong in your data model in the first place, rather than a teamId bolted onto users, is in multi-tenant workspaces in an afternoon.

Install

npm i @buildbase/sdk
react-router
remix
workspaces

Frequently Asked Questions

Should the active workspace live in the URL or the session cookie?

In the URL. A cookie holds one current workspace for the whole browser, so switching in a second tab silently switches the first. A /w/:workspaceId segment keeps each tab in its own workspace and makes every link shareable.

Does a check in a parent loader protect child routes in React Router?

No. Without middleware, React Router runs the loaders of every matched route in parallel, so a child loader can fetch and return data while the parent is still deciding to throw. Use route middleware, or call one helper in every loader and action.

Is multi-tenancy in Remix different in React Router v7 or v8?

The routing is the same: Remix v2 became React Router v7 framework mode. What changed is middleware, stable since 7.9 behind a future flag and always on in v8, which finally gives a parent route a check that runs before its children load.

Is a subdomain per tenant the same as workspaces?

No. Subdomains route by host header, usually to give each customer their own domain. Workspaces let one user belong to several tenants inside one app, and switch between them.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime