How to add per-org data isolation in Next.js
Per-org data isolation in Next.js: check membership once per request, make the data layer demand that proof, and never fetch a record by its ID alone.
In short
Per-org data isolation in Next.js scopes every query against your own tables to a workspace the server verified for this user. BuildBase isolates its own records per org but never sees your tables. Check membership once per request, make the data layer demand that proof, and fetch records by ID and workspace together.
Ask how to isolate each customer's data in a Next.js app built on BuildBase, and half the answer already exists. The other half lives in your own tables, and it comes down to one rule: no query in your app ever receives a workspace ID as a plain string.
The names cross over, so settle them first. In BuildBase, the organization is you: the account you created in the console, which owns your users, plans and settings. Your customers' orgs are workspaces. Per-org isolation for your product means per-workspace isolation, inside a database BuildBase has never seen.
Most Next.js multi-tenancy guides that rank today warn against trusting an org ID from the client. Then they read the active org out of their auth provider's session, or list three places it could come from and leave the choice to you. With BuildBase there is no active workspace in the session to read. The session names the org and the user. Which workspace is open is a choice the browser makes and keeps in its own storage, and a server component only learns it when the request says so.
So the request names the workspace. This guide is about what your server does with that name.
What BuildBase isolates, and what it never sees
BuildBase isolates its own records per organization and checks workspace membership on its own API. Your tables get none of that unless you write it.
Each organization's users, workspaces, subscriptions and usage live in that
organization's own MongoDB database. The authenticated API never takes the org
from the request: it looks up the session that the x-session-id header names
and reads the org off the stored record. Queries in our tenant server then take
the org as their first argument, find(orgId, filter), and that argument is
what picks the database the query runs against.
Inside your organization, BuildBase checks the caller on its workspace routes.
Members, invitations, subscription, usage and credits all sit behind a guard
that asks whether the session's user belongs to the workspace in the path. Call
bb().usage.record(workspaceId, ...) for a workspace the user is not in and
BuildBase answers 403, whatever your code passed.
| Boundary | Who holds it |
|---|---|
| Your organization's data, apart from every other BuildBase org | BuildBase: one database per org, picked from the session |
| Membership, on BuildBase's own workspace calls | BuildBase: a guard on its workspace routes |
| Which workspace this request is for | You: the URL |
| Membership, before your code touches your tables | You ask, BuildBase answers: workspace.permissions() |
| Your rows, filtered to that workspace | You: every query |
The first row costs us something, not you. A database per org means a connection per active org, and roughly 1,000 orgs per deployment is the connection-pool ceiling we plan against. Your workspaces share your org's database instead of getting one each, so the limit you meet is the workspace count on your plan. Not a connection limit. Database-per-tenant versus shared schema is the same decision made one level down, and the last three rows above are its shared-schema half. They are yours.
Before you start
Sign-in working through BuildBase on Next.js,
so bb() and its bb-session cookie exist. Next.js 15 or later on the App
Router; on 14, params is a plain object, so drop the await in front of it.
@buildbase/sdk 0.0.73 or later, the release that added
workspace.permissions(). And a table of your own to protect: the examples
use Postgres through postgres.js, but
the shape holds for any database.
Step 1: put the workspace in the URL
Every page that shows workspace data lives under app/w/[workspaceId]/, and
every API route under app/api/w/[workspaceId]/:
app/
w/[workspaceId]/
layout.tsx nav and switcher - not the guard
projects/
page.tsx
actions.ts
api/w/[workspaceId]/projects/[projectId]/
route.ts
lib/
buildbase.ts bb() and getSessionId, from the setup guide
workspace.ts the only file that verifies a workspace
data/projects.ts the only file that queries your tables
Why the URL? A server component cannot read browser storage, and the session holds no workspace. A cookie would work, but it holds one workspace for the whole browser: switch in a second tab and the first tab's next form post lands in the other workspace. The Remix version of this guide walks through that failure, and nothing about it changes in Next.js. A path segment gives every tab its own workspace and puts the ID in every access log you already keep.
Use the workspace's _id. The workspace object also has a field called
workspaceId, which holds a different value, and BuildBase answers 400 for it.
At this point any ID typed into the address bar reaches your page. The next step decides which ones get any further.
Step 2: turn the ID into a verified workspace, in one file
// lib/workspace.ts
import { cache } from 'react';
import { notFound, redirect } from 'next/navigation';
import { bb } from '@/lib/buildbase';
declare const verified: unique symbol;
/** A workspace the signed-in user was checked against. Only this file makes one. */
export type VerifiedWorkspace = {
readonly id: string;
readonly role: string | null;
readonly isOwner: boolean;
readonly permissions: string[];
readonly [verified]: true;
};
export type WorkspaceCheck =
| { ok: true; workspace: VerifiedWorkspace }
| { ok: false; reason: 'signed-out' | 'no-access' };
export const checkWorkspace = cache(
async (workspaceId: string): Promise<WorkspaceCheck> => {
if (!(await bb().auth())) return { ok: false, reason: 'signed-out' };
try {
const access = await bb().workspace.permissions(workspaceId);
// The one place the brand is granted.
const workspace = { id: workspaceId, ...access } as VerifiedWorkspace;
return { ok: true, workspace };
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 401) return { ok: false, reason: 'signed-out' };
if (status === 400 || status === 403 || status === 404) {
return { ok: false, reason: 'no-access' };
}
throw err; // a timeout or a 5xx: fail closed
}
}
);
/** For pages and layouts: the verified workspace, or sign-in, or a 404. */
export async function workspaceForPage(workspaceId: string) {
const check = await checkWorkspace(workspaceId);
if (check.ok) return check.workspace;
if (check.reason === 'signed-out') redirect('/');
notFound();
}workspace.permissions() asks BuildBase what the session's user may do in
that workspace and gets back { role, isOwner, permissions }, resolved on the
server from the workspace's own members. The
permissions docs show the
endpoint underneath it. A string that is not a valid ID gets
a 400. A workspace the user is not in gets a 403, and so does one that does not
exist, so nobody can probe for which IDs are real. The status sits on the
thrown error but not in the SDK's types, hence the cast.
Only one of the two refusals is about the session. bb().auth() checks that a
session cookie is there, not that it is still alive, so an expired session
surfaces as a 401 from BuildBase and lands in signed-out too. Everything else
is no-access, and it must never look like a dead session. BuildBase's own
guard once answered 401 here, and signed people out for opening a workspace
they had lost access to; the
Express version of this guide tells
that one.
The branded type is the point of the file. VerifiedWorkspace carries a
marker that exists only inside lib/workspace.ts, so the one honest way to get
a value of that type is checkWorkspace(). Anywhere a VerifiedWorkspace is
required, TypeScript refuses a string.
cache() lets a layout and a page that both ask in one render share one round
trip. It does nothing anywhere else, and the Next.js docs say so plainly:
Server Actions and Route Handlers "are not part of a render pass, so the
wrapper does nothing and every call runs the function." So each of them asks
BuildBase afresh. In front of a write, that is the behaviour you want.
What you should see: checkWorkspace() with your own workspace's _id
returns ok: true and your role. Any other ID returns no-access.
Step 3: make your data layer refuse a plain string
This is the decision our docs leave to you: where the check lives. The
tempting answer in Next.js is app/w/[workspaceId]/layout.tsx. Check
membership there, call notFound(), and every page underneath looks guarded.
It is not, and Next.js says so too. Layouts "don't re-render on navigation", and "a layout also does not control whether the rest of the route renders". Server Actions do not pass through the layout's check at all: they arrive as a POST, and the docs tell you to treat them "with the same security considerations as public-facing API endpoints." A check in the layout is a courtesy to the user. The guarantee has to sit where the data is read.
So put it in the signature. Every function that touches a workspace's rows
takes a VerifiedWorkspace as its first argument, the way queries in
BuildBase's own server take the org first. The table carries the workspace,
not the user who created the row, because
the workspace owns the data
and people come and go:
create table projects (
id uuid primary key default gen_random_uuid(),
workspace_id text not null, -- the BuildBase workspace _id
name text not null,
created_at timestamptz not null default now()
);
create index projects_workspace_idx on projects (workspace_id, created_at desc);// lib/data/projects.ts
import postgres from 'postgres';
import type { VerifiedWorkspace } from '@/lib/workspace';
const sql = postgres(process.env.DATABASE_URL!);
export type Project = { id: string; name: string };
export async function listProjects(ws: VerifiedWorkspace) {
return sql<Project[]>`
select id, name from projects
where workspace_id = ${ws.id}
order by created_at desc
`;
}
export async function createProject(
ws: VerifiedWorkspace,
input: { name: string }
) {
const [project] = await sql<Project[]>`
insert into projects (workspace_id, name)
values (${ws.id}, ${input.name})
returning id, name
`;
return project;
}A page gets its workspace from step 2 and hands it down:
// app/w/[workspaceId]/projects/page.tsx
import { listProjects } from '@/lib/data/projects';
import { workspaceForPage } from '@/lib/workspace';
import { createProjectAction } from './actions';
export default async function ProjectsPage({
params,
}: {
params: Promise<{ workspaceId: string }>;
}) {
const { workspaceId } = await params;
const workspace = await workspaceForPage(workspaceId);
const projects = await listProjects(workspace);
return (
<>
<ul>
{projects.map((p) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
<form action={createProjectAction.bind(null, workspace.id)}>
<input name="name" placeholder="Project name" />
<button type="submit">Create</button>
</form>
</>
);
}The Server Action gets no workspace from the page. The bound workspace.id
travels back from the browser as an ordinary argument, and anyone can call the
action with a different one:
// app/w/[workspaceId]/projects/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { createProject } from '@/lib/data/projects';
import { checkWorkspace } from '@/lib/workspace';
export async function createProjectAction(
workspaceId: string,
formData: FormData
) {
// Anyone can call this action, with any workspaceId. Check it here.
const check = await checkWorkspace(workspaceId);
if (!check.ok) throw new Error('No access to this workspace.');
await createProject(check.workspace, {
name: String(formData.get('name') ?? ''),
});
revalidatePath(`/w/${check.workspace.id}/projects`);
}What you should see: delete the checkWorkspace line, pass workspaceId
straight to createProject(), and the build stops with
Argument of type 'string' is not assignable to parameter of type 'VerifiedWorkspace'.
That error is the boundary doing its job. Then run
grep -rn 'as VerifiedWorkspace' app lib: it should print one line, in
lib/workspace.ts. A second line is someone minting the brand by hand, and it
is the one line in a pull request worth reading twice.
Key takeaway
Do not rely on the layout to guard workspace data. Make every function that
reads or writes it take a VerifiedWorkspace, which only checkWorkspace()
can produce. Then no page, Server Action or Route Handler can reach your
tables without the membership check having run first.
Step 4: look records up by ID and workspace together
The membership check answers one question: may this user be in workspace A? It says nothing about the project ID in the same URL. Fetch that project by ID alone and a member of A can open a project from workspace B by pasting its ID under A's path. The check passed. It checked the wrong thing.
We found this shape in our own server. A tenant audit in September looked at BuildBase's invoice lookup, and the comment above the fix records what it found: "Without this check any member of the org could pull any other workspace's invoice by id." The SDK had been sending the workspace ID with every invoice request all along. Nothing on the server compared the two. The fix resolves the workspace's own Stripe customer first and refuses any invoice billed to another, and a foreign invoice now answers 404.
In your tables, every lookup by ID carries the workspace too:
// lib/data/projects.ts (continued)
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export async function renameProject(
ws: VerifiedWorkspace,
projectId: string,
name: string
) {
if (!UUID.test(projectId)) return null; // not a uuid: it cannot exist
const [project] = await sql<Project[]>`
update projects set name = ${name}
where id = ${projectId} and workspace_id = ${ws.id}
returning id, name
`;
return project ?? null;
}// app/api/w/[workspaceId]/projects/[projectId]/route.ts
import { renameProject } from '@/lib/data/projects';
import { checkWorkspace } from '@/lib/workspace';
type Params = Promise<{ workspaceId: string; projectId: string }>;
export async function PATCH(request: Request, { params }: { params: Params }) {
const { workspaceId, projectId } = await params;
const check = await checkWorkspace(workspaceId);
if (!check.ok) {
return check.reason === 'signed-out'
? Response.json({ message: 'Sign in first.' }, { status: 401 })
: Response.json(
{ message: 'No access to this workspace.' },
{ status: 403 }
);
}
const { name } = (await request.json()) as { name?: unknown };
if (typeof name !== 'string' || !name.trim()) {
return Response.json({ message: 'A name is required.' }, { status: 400 });
}
const project = await renameProject(check.workspace, projectId, name.trim());
if (!project)
return Response.json({ message: 'Not found.' }, { status: 404 });
return Response.json(project);
}A project from another workspace is a 404, not a 403. To this user it does not exist, and a 403 would confirm the ID is real.
Now look at what the handler does with a workspaceId in the request body:
nothing. renameProject() takes the workspace from ws.id and the name from
the body, and it has no parameter where a second workspace could go. The
classic version of this bug checks membership on the path's ID and then writes
with the body's. Here a request carries one workspace, and it is the one that
was checked.
What you should see: create a project in workspace B, then send PATCH to
/api/w/<A>/projects/<B's project id> while signed in as a member of both. You
get a 404, because the URL said A and the row says B.
Row-level security is the backstop, not the check
Row-level security makes Postgres refuse rows from every workspace except the
one set for the transaction. It cannot tell you whether that was the right
workspace to set. Hand it an ID straight from the URL and Postgres will
dutifully return that workspace's rows to whoever asked. So it does not
replace step 2. It catches the query someone writes outside
lib/data/, and the one that forgets its where.
alter table projects enable row level security;
alter table projects force row level security;
create policy workspace_isolation on projects
using (workspace_id = current_setting('app.workspace_id', true))
with check (workspace_id = current_setting('app.workspace_id', true));Then each data-layer function sets the verified ID inside its own transaction,
and keeps its where clause:
export async function listProjects(ws: VerifiedWorkspace) {
return sql.begin(async (tx) => {
await tx`select set_config('app.workspace_id', ${ws.id}, true)`;
return tx<Project[]>`
select id, name from projects
where workspace_id = ${ws.id}
order by created_at desc
`;
});
}The true in set_config makes the setting last only for the current
transaction, which is what stops a pooled connection from carrying one
request's workspace into the next request. When nothing is set,
current_setting(..., true) gives back a null or an empty string, the
comparison matches no row, and the query fails closed.
And force is not decoration. Postgres lets table owners skip their own
policies unless the table is forced, and superusers and roles with BYPASSRLS
skip them always. If your app connects as the role that created the table,
force is the line that makes the policy apply to it.
No row-level security in your database? Then the data layer from step 3 is the whole boundary. That is a real boundary. Inside each org database, it is the kind BuildBase draws between your workspaces.
When one customer wants its own database
Some contracts require physical separation. That is the database-per-tenant
side of the decision, and the hosting is often the smallest part of
what it costs. Nothing above changes shape:
VerifiedWorkspace picks the connection instead of filling the where
clause, and step 2 is still the only way to get one.
What breaks, and how you can tell
| What you see | Why, and the fix |
|---|---|
| Every workspace page is a 404, even your own. | The URL carries the workspace's workspaceId field rather than its _id. BuildBase answers 400, and checkWorkspace() reports no-access. |
| A non-member's request still runs a query under a guarded layout. | The check lives only in the layout. Pages, Server Actions and Route Handlers run without it. Make the data layer demand a VerifiedWorkspace, and a call that skips the check stops compiling. |
| Removing someone from one workspace signs them out of all of them. | A membership refusal is mapped to 401 or a sign-in redirect. Only signed-out should do that. |
| A project from another workspace opens under this one. | The query found the project by id alone, so add and workspace_id = ${ws.id} to it and answer 404 when nothing comes back. |
| A malformed project ID answers 500 instead of 404. | Postgres refuses to compare a non-UUID string with a uuid column. Check the shape first, as renameProject() does. |
| Row-level security is on and queries still return every workspace. | The app connects as the table owner on a table that is not forced, or as a superuser. |
The right person, the right workspace, still no-access. | BuildBase does not count them as a member. An accepted invitation that never became a membership looks exactly like this: see an invited member who cannot see the workspace. |
Install
npm i @buildbase/sdk