Guides
Build guide

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.

Dharmendra Jagodana9 min read

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:

RoleWhat it holds by default
adminEvery permission there is, yours included
editorView settings, members, features, billing and usage; record usage and spend credits
viewerView 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 seeWhy, and the fix
Every editor sees the button and every action refuses, admins tooThe 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 creditsA 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 WhenRolesWhenRoles checks the org-level role. The workspace role is WhenWorkspaceRoles, and a permission check beats both.
A demoted member still sees the buttonTheir 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 inThe query is missing workspaceId. The check and the where clause must name the same workspace.
The action throws instead of returning an errorBuildBase 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
rbac
workspaces
nextjs

Frequently Asked Questions

Is hiding a button enough to restrict an action in Next.js?

No. The Next.js docs say Server Functions are reachable via direct POST requests, not just through your UI, and tell you to verify authorization inside every one. WhenPermission decides what renders. The check that stops the action has to run inside the action.

What can a new custom workspace role do before I configure it?

Everything an editor can: view settings, members, features, billing and usage, plus record usage and spend the workspace's credits. Set the new role's column in the permission matrix before you give it to anyone.

How quickly does a workspace role change take effect?

On BuildBase's server, at the next request: the permission guard reads the member's role from the workspace's membership rows every time. The member's open browser tab keeps the old role until the SDK reloads the workspace list.

Can I define a permission that workspace admins do not get?

No. Admin holds every permission, BuildBase's and your own, including one added after the admin column was last saved, and its switches are locked on. Anything admins must not do needs a check in your own code that does not come from the role matrix.

Ship it

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

7-day free trialNo credit card requiredCancel anytime