To control which users can access certain parts of your app, you can use the roles feature. Although Clerk offers roles as part of the organizations feature set, not every app implements organizations. This guide covers a workaround to set up a basic Role Based Access Control (RBAC) system for products that don't use Clerk's organizations or roles.
This guide assumes that you're using Next.js App Router, but the concepts can be adapted to Next.js Pages Router and Remix.
Clerk provides user metadata, which can be used to store information, and in this case, it can be used to store a user's role. Since publicMetadata can only be read but not modified in the browser, it is the safest and most appropriate choice for storing information.
To build a basic RBAC system, you first need to make publicMetadata available to the application directly from the session token. By attaching publicMetadata to the user's session, you can access the data without needing to make a network request each time.
In the Clerk Dashboard, navigate to the Sessions page.
Under Customize session token, in the Claims editor, enter the following JSON and select Save. If you have already customized your session token, you may need to merge this with what you currently have.
In your application's root folder, create a types/ directory.
Inside this directory, create a globals.d.ts file with the following code. This file will provide auto-completion and prevent TypeScript errors when working with roles. For this guide, only the admin and moderator roles will be defined.
types/globals.d.ts
export {}// Create a type for the rolesexporttypeRoles='admin'|'moderator'declare global {interfaceCustomJwtSessionClaims { metadata: { role?:Roles } }}
Create a helper function to simplify checking roles.
In your application's root directory, create a utils/ folder.
Inside this directory, create a roles.ts file with the following code. The checkRole() helper uses the auth() helper to access the user's session claims. From the session claims, it accesses the metadata object to check the user's role. The checkRole() helper accepts a role of type Roles, which you created in the Create a global TypeScript definitionNext.js Icon step. It returns true if the user has that role or false if they do not.
You can customize the behavior of the checkRole() helper function to suit your needs. For example, you could modify it to return the roles a user has or create a protectByRole() function that handles role-based redirects.
To protect the /admin route, choose one of the two following methods:
Middleware: Apply role-based access control globally at the route level. This method restricts access to all routes matching /admin before the request reaches the actual page.
Page-level role check: Apply role-based access control directly in the /admin page component. This method protects this specific page. To protect other pages in the admin dashboard, apply this protection to each route.
Important
You only need to follow one of the following methods to secure your /admin route.
In your app's root directory, create a middleware.ts file with the following code. The createRouteMatcher() function identifies routes starting with /admin. clerkMiddleware() intercepts requests to the /admin route, and checks the user's role in their metadata to verify that they have the admin role. If they don't, it redirects them to the home page.
middleware.ts
import { clerkMiddleware, createRouteMatcher } from'@clerk/nextjs/server'import { NextResponse } from'next/server'constisAdminRoute=createRouteMatcher(['/admin(.*)'])exportdefaultclerkMiddleware(async (auth, req) => {// Protect all routes starting with `/admin`if (isAdminRoute(req) && (awaitauth()).sessionClaims?.metadata?.role !=='admin') {consturl=newURL('/',req.url)returnNextResponse.redirect(url) }})exportconstconfig= { matcher: [// Skip Next.js internals and all static files, unless found in search params'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',// Always run for API routes'/(api|trpc)(.*)', ],}
Add the following code to the app/admin/page.tsx file. The checkRole() function checks if the user has the admin role. If they don't, it redirects them to the home page.
app/admin/page.tsx
import { checkRole } from'@/utils/roles'import { redirect } from'next/navigation'exportdefaultasyncfunctionAdminDashboard() {// Protect the page from users who are not adminsconstisAdmin=awaitcheckRole('admin')if (!isAdmin) {redirect('/') }return <p>This is the protected admin dashboard restricted to users with the `admin` role.</p>}
In your app/admin/ directory, create an _actions.ts file with the following code. The setRole() action checks that the current user has the admin role before updating the specified user's role using Clerk's JavaScript Backend SDKClerk Icon. The removeRole() action removes the role from the specified user.
app/admin/_actions.ts
'use server'import { checkRole } from'@/utils/roles'import { clerkClient } from'@clerk/nextjs/server'exportasyncfunctionsetRole(formData:FormData) {constclient=awaitclerkClient()// Check that the user trying to set the role is an adminif (!checkRole('admin')) {return { message:'Not Authorized' } }try {constres=awaitclient.users.updateUserMetadata(formData.get('id') asstring, { publicMetadata: { role:formData.get('role') }, })return { message:res.publicMetadata } } catch (err) {return { message: err } }}exportasyncfunctionremoveRole(formData:FormData) {constclient=awaitclerkClient()try {constres=awaitclient.users.updateUserMetadata(formData.get('id') asstring, { publicMetadata: { role:null }, })return { message:res.publicMetadata } } catch (err) {return { message: err } }}
In your app/admin/ directory, create a SearchUsers.tsx file with the following code. The <SearchUsers /> component includes a form for searching for users. When submitted, it appends the search term to the URL as a search parameter. Your /admin route will then perform a query based on the updated URL.
With the server action and the search form set up, it's time to refactor the app/admin/page.tsx.
Replace the code in your app/admin/page.tsx file with the following code. It checks whether a search parameter has been appended to the URL by the search form. If a search parameter is present, it queries for users matching the entered term. If one or more users are found, the component displays a list of users, showing their first and last names, primary email address, and current role. Each user has Make Admin and Make Moderator buttons, which include hidden inputs for the user ID and role. These buttons use the setRole() server action to update the user's role.
The foundation of a custom RBAC (Role-Based Access Control) system is now set up. Roles are attached directly to the user's session, allowing your application to access them without the need for additional network requests. The checkRole() helper function simplifies role checks and reduces code complexity. The final component is the admin dashboard, which enables admins to efficiently search for users and manage roles.