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.
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.
The workspace goes in the URL, not the cookie
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