How to add roles and permissions per workspace in Next.js
Per-workspace roles in Next.js: hide controls with WhenPermission, check every server action with bb.workspace.can(), and know which side lags after a role change.
In short
Per-workspace roles in Next.js need two checks. WhenPermission decides what the browser shows. Every server action and route handler calls bb.workspace.can() before it acts, because hiding a button does not hide the action. BuildBase reads roles per request, so after a role change the browser is behind.
<WhenPermission> hides the Delete button from a viewer. It does not stop a
viewer deleting.
In Next.js the delete is a server action, and the Next.js docs are blunt about what that means: Server Functions "are reachable via direct POST requests, not just through your application's UI." The button was one way to call it. Anyone holding the page has the others.
So the real decision in per-workspace roles is where each permission gets
enforced, and the split is cleaner than it sounds. BuildBase enforces its own
workspace: permissions on its own API. Checkout, a plan change and
cancelling need workspace:billing:manage; spending credits needs
workspace:usage:record; changing someone's role needs
workspace:members:role-change. A viewer who calls checkout directly gets a 403
whatever your UI rendered.
Your permissions are a different animal. app:reports:delete is something
BuildBase stores, resolves per role and answers questions about. It has never
seen your server action and cannot guard it. That check is yours, in every
action.
What you are building: a Delete report button a viewer never sees, a server action that refuses a viewer who calls it anyway, and a role matrix where you chose what a new role can do instead of inheriting it.
Before you start
A Next.js App Router app with sign-in working and the bb() helper from
BuildBase on Next.js, plus workspaces that hold
more than one person (multi-tenant workspaces in an
afternoon). Personal mode
turns invitations off, so there is nobody
to give a role to. @buildbase/sdk 0.0.73 or later, which added
workspace.can(). Custom roles and your own permissions are plan limits
(max-custom-workspace-roles and max-custom-workspace-permissions); without
them you still have the three built-in roles and BuildBase's own permissions.
1. Start from the three roles, and look at what a new one inherits
Every organization starts with three workspace roles. There is no owner role: whoever creates a workspace is made its admin, and only they can delete it. Before you change anything, the roles hold this:
| Role | What it holds by default |
|---|---|
admin | Every permission there is, yours included |
editor | View settings, members, features, billing and usage; record usage and spend credits |
viewer | View settings and members |
Someone who joins without being given a role lands on viewer. You can pick a
different default, but never admin: the server reads a stored default of
admin as viewer, so admin is only ever handed out on purpose.
The decision hiding in that table is what a role you add starts with. A
custom role starts as a copy of editor, and editor holds
workspace:usage:record: record metered usage, spend the workspace's credits.
Add a contractor role for someone who should only read reports, assign it
before you open its column, and they can spend the workspace's money.
The irony is that this permission was added to close a gap of exactly this
shape. Before it existed, says the comment above it in our constants, a
viewer "could drain a workspace's credits or run up its metered Stripe bill."
So set a new role's column before anyone holds it. Defaults are for the three
roles you did not have to think about.
One more thing to know before you design anything: there is no permission an admin lacks. Admin resolves to every key the organization knows, including one added after admin's column was last saved, and the console locks its switches on for that reason. If finance should approve refunds and your admins should not, the matrix cannot say so. That is a real limit, and it is better found here than in production. A rule like that belongs in your own code, against your own list of approvers, not against a role.
What you should see: in the console under Workspaces > Settings, a permission matrix with BuildBase's twelve permissions as rows, your roles as columns, and the admin column locked on.
2. Define your permission in the console, not only in code
Your permission has to exist somewhere the server can read it. That place is the console.
Add it beside the matrix as a key, a label and an optional group. The key is
what your code compares, so it is strict: lowercase letters, digits and
. : _ -, up to 64 characters, with workspace:, buildbase:, system: and
org: reserved. We use app:<area>:<action>, which makes this guide's key
app:reports:delete. Grant it to editor and leave viewer alone. Renaming,
removal and the change history are in the
permissions docs.
Here is the trap. SaaSOSProvider also takes a defaultPermissions prop, and
our own
checking permissions
page still offers it as the way to add app permissions. A key that lives only
in that prop
resolves in the browser and nowhere else, because the server never reads your
provider props. It knows the keys a matrix grants: the organization's, or a
workspace's saved override. Put app:reports:delete in the prop alone and
every editor sees the button, while the server action in step 4 refuses every
one of them. Admins too, the workspace's creator included.
What you should see: an app:reports:delete row in the matrix, on for admin
(locked) and editor, off for viewer.
3. Hide the button in the browser
This is the convenience half. It spares a viewer a button that could only fail.
// app/reports/delete-report-button.tsx
'use client';
import { useState } from 'react';
import { useSaaSWorkspaces, WhenPermission } from '@buildbase/sdk/react';
import { deleteReport } from './actions';
export function DeleteReportButton({ reportId }: { reportId: string }) {
const { currentWorkspace, refreshWorkspaces } = useSaaSWorkspaces();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!currentWorkspace) return null;
const workspaceId = currentWorkspace._id;
async function onDelete() {
setPending(true);
setError(null);
const result = await deleteReport(workspaceId, reportId);
setPending(false);
if (!result.ok) {
setError(result.error);
// This tab's copy of the member's role was out of date. Reload it.
await refreshWorkspaces();
}
}
return (
<WhenPermission permission="app:reports:delete">
<button type="button" onClick={onDelete} disabled={pending}>
{pending ? 'Deleting...' : 'Delete report'}
</button>
{error && <p role="alert">{error}</p>}
</WhenPermission>
);
}WhenPermission answers for the current workspace, the one the switcher
shows. The refreshWorkspaces() line looks odd until the section after step
4, which is about the moment it runs.
What you should see: signed in as a viewer, no button. As an editor, the button.
4. Check again in the server action
This is the step that makes it security. Put the question in one helper, so every action asks it the same way:
// lib/permissions.ts
import { bb } from '@/lib/buildbase';
/**
* BuildBase's answer for the signed-in user. False when they are not a
* member; throws when BuildBase cannot answer, so nothing runs on a guess.
*/
export async function can(workspaceId: string, permission: string) {
try {
return await bb().workspace.can(workspaceId, permission);
} catch (err) {
const status = (err as { status?: number }).status;
if (status === 400 || status === 403 || status === 404) return false;
throw err;
}
}// app/reports/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db'; // your own database client
import { can } from '@/lib/permissions';
export async function deleteReport(workspaceId: string, reportId: string) {
if (!(await can(workspaceId, 'app:reports:delete'))) {
return {
ok: false as const,
error: 'Your role cannot delete reports in this workspace.',
};
}
await db.report.deleteMany({ where: { id: reportId, workspaceId } });
revalidatePath('/reports');
return { ok: true as const };
}bb().workspace.can() is one request to BuildBase's permissions/me
endpoint, and the answer is the server's own resolution, in the same order the
server checks its own permissions: the workspace's override first, then the
organization's matrix, then the defaults. Non-members get a 403. The helper
turns that into false and rethrows a timeout or a 5xx, so the action fails
instead of guessing.
Both arguments came from the browser, so treat both as claims. can() settles
whether this user may delete reports in workspaceId; the where clause
settles that the report is actually in workspaceId, and without it an editor
of their own workspace can delete a report in someone else's just by sending
its ID. Check and query, same workspace.
What you should see: move the button outside <WhenPermission> for a minute,
sign in as a viewer and click it. The action returns the error and the report
stays. Then put the wrapper back.
Why not bb().permissions.check(workspaceId, userId, permission)? It takes a
user ID, so it can answer for any member, which suits an admin screen showing
what a teammate may do. For the signed-in user it is the weaker choice. It fetches the workspace, the
organization's settings and the member list, three requests, and works the
answer out in your process with the SDK's copy of the rules. workspace.can()
asks once and gets the server's answer.
A route handler is the same check with a status code:
// app/api/reports/route.ts
import { db } from '@/lib/db';
import { can } from '@/lib/permissions';
export async function DELETE(request: Request) {
const { workspaceId, reportId } = await request.json();
if (!(await can(workspaceId, 'app:reports:delete'))) {
return Response.json({ error: 'Forbidden' }, { status: 403 });
}
await db.report.deleteMany({ where: { id: reportId, workspaceId } });
return new Response(null, { status: 204 });
}403, never 401. A 401 tells a client the session is gone, a well-behaved client signs the user out on it, and the Express version of this check explains why BuildBase's own API stopped answering 401 for membership failures.
Got a page with a dozen gated controls? Ask once.
bb().workspace.permissions(workspaceId) returns
{ role, isOwner, permissions }, and a permissions.includes() per control
costs nothing.
These snippets typecheck under strict TypeScript against @buildbase/sdk
0.0.82 and Next.js 14.2. I have not run them against a live BuildBase API, so
treat them as the shape of the check rather than a transcript.
Key takeaway
WhenPermission decides what renders. can() inside the action decides what
happens. BuildBase enforces its own workspace: permissions on its own API;
only your code can enforce yours.
After a role changes, the browser is the side that is behind
BuildBase's server sees a workspace role change on the next request. The member's open tab does not see it until the SDK reloads the workspace list.
The server first. The guard behind every workspace permission, and the endpoint
can() asks, both look up the member's role in the workspace's membership rows
on each request. Changing a role rewrites that row, with no cache in between.
Matrix edits land the same way. The organization's settings sit in Redis, and
saving them clears that copy along with the public settings route the SDK
reads, which otherwise caches for 5 minutes. A workspace's own override is
stored on the workspace and read per request.
The browser is another story. usePermissions() and WhenPermission resolve
from the workspace the SDK already loaded, taking the member's role from that
workspace's member list, and nothing tells an open tab its role changed.
Demote someone from editor to viewer and their tab keeps showing Delete.
They click, can() says no, and the refreshWorkspaces() call from step 3
reloads the list so the button goes away instead of failing twice.
This is the mirror image of the org-level role, which a session carries. Changing that one through the console revokes the member's sessions at once, and only a write that bypasses the change-role route waits up to an hour for the session to refresh. A workspace role is not carried in the session at all. Here the guard is current and the UI lags.
We do not push role changes to open tabs. The server-side check is what makes
that acceptable, since the worst a stale tab can do is show a button that fails
politely. Promotions run the other way. The newly promoted member sees nothing
new until the list reloads, so if roles change often in your product, call
refreshWorkspaces() when the window regains focus and pay for one
workspace-list request each time.
PlugNode, one of our own products, runs its team workspaces with RBAC on this module.
What breaks, and how you can tell
| What you see | Why, and the fix |
|---|---|
| Every editor sees the button and every action refuses, admins too | The key lives only in defaultPermissions on SaaSOSProvider, which the server never reads. Define it in the console and grant it in the matrix. |
| A role you just added can spend the workspace's credits | A custom role starts as editor, and editor holds workspace:usage:record. Turn it off in the new role's column. |
A workspace admin cannot see something inside WhenRoles | WhenRoles checks the org-level role. The workspace role is WhenWorkspaceRoles, and a permission check beats both. |
| A demoted member still sees the button | Their tab holds the workspace it loaded. The action refuses them, and refreshWorkspaces() brings the UI up to date. |
| A report vanished from a workspace the deleter was not in | The query is missing workspaceId. The check and the where clause must name the same workspace. |
| The action throws instead of returning an error | BuildBase did not answer. The helper rethrows on purpose, so a timeout never reads as a yes or a no. |
One boundary worth keeping clear. If what you are gating is something the customer paid for rather than something a person's role allows, that is a plan question and a different system: entitlements are not feature flags.
Install
npm i @buildbase/sdk