Organizations and Teams with Role-Based Access Control in Makerkit Next.js Prisma

Apply role-based access control to TeamPulse. Define who can create boards, manage feedback, and change statuses based on roles.

In this module, you'll apply Makerkit's RBAC system to TeamPulse features: controlling who can create boards, delete feedback, and manage statuses based on their role.

At the end of this module, you'll have a clear understanding of how to apply role-based access control to features in your application by membership roles.

In the next few modules, instead, we will be looking at how to apply access control to features by subscription plans.

Technologies used:

What you'll accomplish:

  • Understand the role hierarchy (Owner > Admin > Member)
  • Gate board and feedback actions by role
  • Build role-aware UI components
  • Handle permission errors gracefully

Prerequisites: Complete Module 5: Authentication first.


TeamPulse Permission Matrix

Before writing code, define what each role can do in TeamPulse:

ActionMemberAdminOwner
Boards
View all boardsYesYesYes
Create boardsNoYesYes
Edit any boardNoYesYes
Delete boardsNoYesYes
Make board publicNoYesYes
Feedback
Submit feedbackYesYesYes
Vote on feedbackYesYesYes
Edit own feedbackYesYesYes
Delete own feedbackYesYesYes
Edit any feedbackNoYesYes
Delete any feedbackNoYesYes
Change feedback statusNoYesYes
Organization
View membersYesYesYes
Invite membersNoYesYes
Remove membersNoYes (not owners)Yes
Change member rolesNoYes (to member only)Yes
Manage billingNoYesYes
Delete organizationNoNoYes

This gives members full access to use the app (view, submit, vote) while reserving management actions for admins and owners.


Understanding the Role Hierarchy

Makerkit uses a hierarchical role system with numeric levels:

RoleLevelDescription
Owner100Full control - billing, org deletion, ownership transfer
Admin50Team management, feature configuration
Member10Use features, submit feedback, vote

Key rule: Members can only manage other members with a lower hierarchy level than theirs.

This is the default behavior of the kit. You can customize it by modifying the ROLE_HIERARCHY configuration in the @kit/rbac package. For more information please refer to the Roles and Permissions Documentation.


Step 1: Understanding Kit Permissions

Makerkit provides built-in permission utilities that you can use to check permissions in both your server and client components.

To better clarify when to use client or server-side checks, here's a quick summary:

  • Server-side: Use to enforce permissions in server actions and database queries. This is the ultimate source of truth for permissions - and should be used to check permissions before performing any action (read, write, delete, etc.).
  • Client-side: Use to check permissions in client components and display UI elements conditionally. This is purely for user-experience purposes - and should be used to hide UI elements that are not allowed to be accessed by the current user.

Server-Side Authorization

To protect server actions based on roles, or permissions, you can use the withMinRole and withPermission middleware from the @kit/action-middleware package.

  • withMinRole('admin') - Requires minimum role level
  • withFeaturePermission('canManageSubscriptions') - Checks feature permissions

Usage

Below is a usage example of the withMinRole middleware to protect a server action to only allow admins and owners to create boards.

import { authenticatedActionClient } from '@kit/action-middleware';
import { withMinRole } from '@kit/action-middleware';
export const createBoardAction = authenticatedActionClient
.use(withMinRole('admin'))
.inputSchema(createBoardSchema)
.action(async ({ parsedInput: data, ctx }) => {
// withMinRole adds ctx.role and ctx.organizationId
// Only admins/owners reach this point - members get an error
});

Client-Side UI Checks

To check permissions in client components, you can use the isAtLeastRole function from the @kit/organizations/client-helpers package.

  • isAtLeastRole('admin', role) - Check if role meets minimum level
  • canManageMember(targetRole, currentRole) - Check if user can manage another member
  • isOwner(role) - Check if user is the owner

Important: Client-side checks are for UX only (ex. hiding buttons, etc.). Server-side middleware enforces actual authorization.


Step 2: Gate Server Actions

Now, we can apply the permissions to the server actions in TeamPulse and see a practical example of how to use the Server Action middleware (not to be confused with the Next.js proxy/middleware!).

Board Actions

Let's modify the createBoardAction server action to only allow admins and owners to create boards. We'll use the withMinRole middleware to enforce the permission:

apps/web/lib/boards/boards-server-actions.ts

import { authenticatedActionClient } from '@kit/action-middleware';
import { withMinRole } from '@kit/action-middleware';
export const createBoardAction = authenticatedActionClient
.use(withMinRole('admin'))
.inputSchema(createBoardSchema)
.action(async ({ parsedInput: data, ctx }) => {
// withMinRole adds ctx.role and ctx.organizationId
// Only admins/owners reach this point - members get an error
const logger = await getLogger();
logger.info(
{ userId: ctx.user.id, organizationId: ctx.organizationId, name: data.name },
'Creating board',
);
// ... create board logic
});

The withMinRole('admin') middleware:

  • Verifies the user has at least admin role
  • Throws "Unauthorized: admin role required" if not
  • Provides ctx.role and ctx.organizationId to the action

Feedback Actions with Author Check

For actions where authors can edit their own content but admins can edit anyone's.

Note: we're replacing the authenticatedActionClient with the organizationActionClient to be able to populate the ctx.organizationId and ctx.role in the action context.

The organizationActionClient is a wrapper around the authenticatedActionClient that populates the ctx.organizationId and ctx.role in the action context with the current active organization and role of the user for that organization.

import { getActiveOrganizationId } from '@kit/better-auth/context';
export const updateFeedbackAction = organizationActionClient
.inputSchema(updateFeedbackSchema)
.action(async ({ parsedInput: data, ctx }) => {
const organizationId = ctx.organizationId;
// Get the feedback to check author
const existing = await verifyFeedbackAccess(data.id, organizationId);
if (!existing) {
throw new Error('Feedback not found');
}
// Permission check: author or admin/owner
const isAuthor = existing.authorId === ctx.user.id;
const isAdmin = ctx.role === 'admin' || ctx.role === 'owner';
if (!isAuthor && !isAdmin) {
throw new Error('You can only edit your own feedback');
}
// ... update feedback logic
});

Step 3: Role-Aware UI Components

Pass the role from your server component to client components as a prop, then use pure functions for permission checks.

Load Role in Page (Server Component)

// apps/web/app/[locale]/(internal)/boards/page.tsx
import { loadCurrentUserRole } from '@kit/organization-settings/lib/loaders/members-page.loader';
export default async function BoardsPage() {
const boards = await loadBoards();
const role = await loadCurrentUserRole();
return (
<PageBody>
<BoardsHeader role={role} />
<BoardsList boards={boards} role={role} />
</PageBody>
);
}

Check Permissions in Client Component

'use client';
import { isAtLeastRole } from '@kit/organizations/client';
interface BoardsHeaderProps {
role: string | null;
}
export function BoardsHeader({ role }: BoardsHeaderProps) {
const canCreateBoard = isAtLeastRole('admin', role);
return (
<PageHeader>
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Feedback Boards</h1>
<p className="text-muted-foreground">
Collect feedback from your team
</p>
</div>
{canCreateBoard && <CreateBoardDialog />}
</div>
</PageHeader>
);
}

Custom Permissions with Better Auth RBAC

The withMinRole example works for simple hierarchy checks ("admins and above").

For more granular control, I highly recommend using Better Auth's resource-based permission system.

In the next few steps, we will see how to define custom permissions with Better Auth's resource-based permission system.

1. Define the Resource

First, we need to define the resources that we want to protect. In our case, we have two resources: board and feedback.

packages/rbac/src/permissions/custom/custom-resources.ts

export const CUSTOM_RESOURCES = {
// Add custom resources here
BOARD: 'board',
FEEDBACK: 'feedback',
} as const;

2. Update the Access Controller

Next, we want to update the access controller to define the actions that are available for the resources. In our case, we want to allow owners and admins to create, update, and delete boards and feedback.

We can do this by updating the CUSTOM_ACCESS_CONTROLLER configuration in the packages/rbac/src/custom-access-controller.ts file:

packages/rbac/src/custom/custom-access-controller.ts

import { ACTIONS, RESOURCES } from '@kit/rbac/permissions';
import type { Action, Resource } from '@kit/rbac/permissions';
export const CUSTOM_ACCESS_CONTROLLER = {
// Add custom resources and actions here
[RESOURCES.BOARD]: [
ACTIONS.CREATE,
ACTIONS.READ,
ACTIONS.UPDATE,
ACTIONS.DELETE,
],
[RESOURCES.FEEDBACK]: [
ACTIONS.CREATE,
ACTIONS.READ,
ACTIONS.UPDATE,
ACTIONS.DELETE,
],
};

3. Assign Permissions to Roles

Finally, we need to assign the permissions to the roles. We can do this by updating the CUSTOM_ROLE_PERMISSIONS configuration in the packages/rbac/src/custom/custom-role-permissions.ts file:

Here's the matrix of permissions we want to assign to the roles:

RoleBoardFeedback
OwnerCreate, Read, Update, DeleteCreate, Read, Update, Delete
AdminCreate, Read, UpdateCreate, Read, Update
MemberReadRead

packages/rbac/src/custom/custom-role-permissions.ts

export const CUSTOM_ROLE_PERMISSIONS: Partial<
Record<
Role,
Partial<{
[key in keyof AC]: AC[key];
}>
>
> = {
owner: {
[RESOURCES.BOARD]: [
ACTIONS.CREATE,
ACTIONS.READ,
ACTIONS.UPDATE,
ACTIONS.DELETE,
],
[RESOURCES.FEEDBACK]: [
ACTIONS.CREATE,
ACTIONS.READ,
ACTIONS.UPDATE,
ACTIONS.DELETE,
],
},
admin: {
[RESOURCES.BOARD]: [ACTIONS.CREATE, ACTIONS.READ, ACTIONS.UPDATE],
[RESOURCES.FEEDBACK]: [ACTIONS.CREATE, ACTIONS.READ, ACTIONS.UPDATE],
},
member: {
[RESOURCES.BOARD]: [ACTIONS.READ],
[RESOURCES.FEEDBACK]: [ACTIONS.READ],
},
};

These permissions are automatically merged with the default role definitions.

Note: for simplicity, we will only be implementing at one permission, e.g. canCreateBoard; however, the implementation will be the same for any other permission. Learning one will help you understand the others.

4. Use in Server Actions

We can now use the withFeaturePermission middleware to protect a server action to only allow owners and admins to create boards.

The withFeaturePermission middleware takes a single object with the resource and the action to protect, fully type-safe.

apps/web/lib/boards/boards-server-actions.ts

import { authenticatedActionClient } from '@kit/action-middleware';
import { withFeaturePermission } from '@kit/action-middleware';
export const createBoardAction = authenticatedActionClient
.use(
withFeaturePermission({
board: ['create'],
}),
)
.inputSchema(createBoardSchema)
.action(async ({ parsedInput, ctx }) => {
// Only roles with board:create permission reach here
});

The withFeaturePermission middleware:

  • Checks Better Auth's actual permission system
  • Throws "Unauthorized: board:create" if denied
  • Works with custom roles from Dynamic Access Control

5. Gate features client-side based on permissions

The simplest way to gate features client-side based on permissions is to use the hasPermission function from the @kit/better-auth package.

import { auth } from '@kit/better-auth';
const { success } = await auth.api.hasPermission({
headers: await headers(),
body: {
permissions: {
board: ['create'],
},
},
});

You can also pass multiple permissions to the hasPermission function.

const { success } = await auth.api.hasPermission({
headers: await headers(),
body: {
permissions: {
board: ['create'],
feedback: ['create'],
},
},
});

For more information on the hasPermission function, please refer to the Better Auth Documentation.

In the boards page, we can now gate functionality based on whether the current user has the permission to create boards.

apps/web/app/[locale]/(internal)/boards/page.tsx

import type { Metadata } from 'next';
import { headers } from 'next/headers';
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { loadBoards } from '@lib/boards/boards-page.loader';
import { auth } from '@kit/better-auth';
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';
import { Button } from '@kit/ui/button';
import {
CardButton,
CardButtonHeader,
CardButtonTitle,
} from '@kit/ui/card-button';
import {
EmptyState,
EmptyStateButton,
EmptyStateHeading,
EmptyStateText,
} from '@kit/ui/empty-state';
import { If } from '@kit/ui/if';
import { PageBody, PageHeader } from '@kit/ui/page';
import { CreateBoardDialog } from './_components/create-board-dialog';
export const metadata: Metadata = {
title: 'Feedback Boards',
description: 'Manage your feedback boards',
};
export default async function BoardsPage() {
const [boards, { success: canCreateBoard }] = await Promise.all([
loadBoards(),
auth.api.hasPermission({
headers: await headers(),
body: {
permissions: {
board: ['create'],
},
},
}),
]);
return (
<PageBody>
<PageHeader>
<div className="flex-1">
<AppBreadcrumbs />
</div>
<If condition={canCreateBoard}>
<CreateBoardDialog>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Create Board
</Button>
</CreateBoardDialog>
</If>
</PageHeader>
{boards.length === 0 ? (
<EmptyStateSection canCreateBoard={canCreateBoard} />
) : (
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4">
{boards.map((board) => (
<BoardCard key={board.id} board={board} />
))}
</div>
)}
</PageBody>
);
}
function EmptyStateSection({ canCreateBoard }: { canCreateBoard: boolean }) {
return (
<EmptyState>
<EmptyStateHeading>No boards yet</EmptyStateHeading>
<EmptyStateText>
Create your first feedback board to start collecting feedback.
</EmptyStateText>
<If condition={canCreateBoard}>
<CreateBoardDialog>
<EmptyStateButton asChild>
<Button size="sm">
<Plus className="mr-2 h-4 w-4" />
Create Board
</Button>
</EmptyStateButton>
</CreateBoardDialog>
</If>
</EmptyState>
);
}
function BoardCard({
board,
}: {
board: Awaited<ReturnType<typeof loadBoards>>[number];
}) {
return (
<Link className="w-full" href={`/boards/${board.id}`}>
<CardButton asChild>
<CardButtonHeader className="text-left">
<CardButtonTitle>{board.name}</CardButtonTitle>
{board.description && (
<span className="text-muted-foreground max-w-full text-xs">
{board.description}
</span>
)}
</CardButtonHeader>
</CardButton>
</Link>
);
}

Step 4: Handling Permission Errors

When a server action denies permission, handle the error gracefully in your client component. For example, in the below example, we are using the useAction hook to handle the error gracefully in the client component by displaying a toast notification when the board creation fails.

'use client';
import { useAction } from 'next-safe-action/hooks';
import { toast } from '@kit/ui/sonner';
import { Button } from '@kit/ui/button';
import { createBoardAction } from '@lib/boards/boards-server-actions';
export function CreateBoardButton() {
const { execute, status } = useAction(createBoardAction, {
onSuccess: () => {
toast.success('Board created');
},
onError: ({ error }) => {
// Server action throws when user lacks permission
toast.error(error.serverError ?? 'Failed to create board');
},
});
return (
<Button
onClick={() => execute({ name: 'New Board' })}
disabled={status === 'executing'}
>
Create Board
</Button>
);
}

The server action middleware throws descriptive errors like "Unauthorized: admin role required" which appear in error.serverError.


Step 5: Test the Invitation Flow

Test with different roles to verify permissions work correctly.

Send an Invitation

  1. Go to /settings/members
  2. Click Invite Member
  3. Enter an email address
  4. Select Member role
  5. Click Send Invitation

Accept the Invitation

  1. Open Mailpit at http://localhost:8025
  2. Click the invitation link
  3. Sign up with the invited email
  4. Accept the invitation

Verify Permissions

Sign in as the new member and verify:

  1. Can view boards - Boards list page loads
  2. Cannot create boards - "Create Board" button is hidden
  3. Can submit feedback - "Add Feedback" button works
  4. Cannot change status - Status shows as badge, not dropdown
  5. Can edit own feedback - Actions menu shows Edit
  6. Cannot edit others' feedback - No actions menu on others' items

Checkpoint: Verify It Works

Test with different roles:

As Owner:

  • [ ] Can create, edit, delete boards
  • [ ] Can edit/delete any feedback
  • [ ] Can change any feedback status
  • [ ] Can invite and manage members

As Admin:

  • [ ] Can create, edit, delete boards
  • [ ] Can edit/delete any feedback
  • [ ] Can change any feedback status
  • [ ] Can invite members (but not owners)

As Member:

  • [ ] Can view boards
  • [ ] Cannot create boards (button hidden)
  • [ ] Can submit and vote on feedback
  • [ ] Can edit/delete own feedback only
  • [ ] Cannot change feedback status

Run quality checks:

pnpm typecheck
pnpm lint:fix
pnpm format:fix

Module 7 Complete!

You now have:

  • [x] TeamPulse permission matrix defined
  • [x] Server-side permission checks using kit middleware
  • [x] Role-aware UI components using props
  • [x] Author-specific feedback permissions
  • [x] Graceful error handling for permission denials

Next: In Module 8: Billing & Subscriptions, you'll configure Stripe with TeamPulse-specific plan limits.


Learn More