Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.
AuthorNick Vogel

Setting up Auth and Role-Based Access Control in Next.js + Payload

Community Guide
AuthorNick Vogel

In this guide, you’ll learn how to configure basic authentication in Payload, define roles (admin, editor, user, and anyone), and apply role-based access across your collections and frontend.

When building apps with Payload, it's important to establish clear access boundaries across different types of users. In this guide, we'll walk through how to set up authentication and define roles using Payload’s built-in auth system.

We’ll define four user roles:

  • Admin – Full access to the admin panel, including the ability to delete users.
  • Editor – Can access and manage content in the posts collection but can’t edit users.
  • User – Can view their own profile and has read access to collections, but can’t modify anything else.
  • Anyone – Represents public or unauthenticated access, useful for content that's accessible to all visitors.

Using these roles, we'll implement fine-grained access control both in the Payload admin UI and within our frontend, laying the foundation for a secure, multi-role application.

1. Configure the Users Collection

Payload automatically generates a default Users collection when you start a new project. First, move and rename the users.ts file to config.ts for better project structure:

1
# Move users collection to its own folder
2
collections/
3
users/ (we're creating this directory)
4
config.ts (formerly users.ts)

Then, add a roles field to your users/config.ts under our avatar:

1
{
2
name: 'roles',
3
type: 'select',
4
hasMany: true,
5
saveToJWT: true,
6
options: [
7
{ label: 'Admin', value: 'admin' },
8
{ label: 'Editor', value: 'editor' },
9
{ label: 'User', value: 'user' },
10
],
11
hooks: {
12
beforeChange: [protectRoles], // we'll define this next
13
},
14
}

2. Protect Role Assignments

To prevent non-admins from assigning themselves admin access, create the following file structure:

1
# Create a hook folder within Users
2
collections/
3
users/
4
hooks/
5
protectRoles.ts

hooks/protectRoles.ts:

1
import type { FieldHook } from 'payload/types';
2
import type { User } from '@/payload-types';
3
4
export const protectRoles: FieldHook<{ id: string } & User> = ({ req, data }) => {
5
const isAdmin = req.user?.roles?.includes('admin');
6
7
if (!isAdmin) {
8
return ['user']; // non-admins are forced to 'user' role
9
}
10
11
const userRoles = new Set(data?.roles || []);
12
userRoles.add('user'); // ensure 'user' is always included
13
14
return [...userRoles.values()];
15
};

If 'roles' is red, generate types if needed: payload generate:types

Testing the hook

Before applying access control across the app, let’s verify that our protectRoles hook is working:

  1. In the Payload admin dashboard, go to Users.
  2. Edit a user and try assigning them the admin role.
  3. After saving, you’ll notice the role defaults back to just user. This confirms that the hook is preventing non-admin users from assigning elevated roles.

To seed your initial admin accounts:

  1. Temporarily disable the hook in your config.
  2. Assign admin to the necessary users manually via the dashboard.
  3. Re-enable the hook to lock things down again.

3. Define Access Control

Create an Users/access/ folder with the following files:

anyone.ts:

1
import type { Access } from 'payload'
2
export const anyone: Access = () => true;

checkRole.ts: This is a helper function that streamlines access control by checking whether a user has a specific role. It's used to define permission levels across the app—ensuring that only authenticated users with the right role (like admin, editor, or user) can access certain resources, while preventing unauthenticated users from doing so.

It keeps access logic clean and reusable throughout your config.

1
import type {User} from '@/payload-types'
2
3
export const checkRole = (allRoles: User['roles'] = [], user: User): boolean => {
4
5
if (user) {
6
if (
7
allRoles?.some((role) => {
8
return user?.roles?.some((individualRole) => {
9
return individualRole === role
10
})
11
})
12
) {
13
return true
14
}
15
}
16
return false
17
18
}

user.ts

1
import type {Access} from 'payload'
2
import { checkRole } from './checkRole'
3
4
// In user.ts, the checkRole function is used to determine if the current user is an admin or editor. If so, they’re granted full access; otherwise, access is limited to their own user record.
5
6
const user: Access = ({ req: { user } }) => {
7
if (user) {
8
if (checkRole(['admin', 'editor'], user)) {
9
return true
10
}
11
12
return {
13
id: { equals: user.id }
14
}
15
}
16
17
return false
18
}
19
20
export default user

editor.ts

1
import type {Access} from 'payload'
2
import { checkRole } from './checkRole'
3
4
const editor: Access = ({ req: { user } }) => {
5
if (user) {
6
if (checkRole(['admin', 'editor'], user)) {
7
return true
8
}
9
}
10
11
return false
12
}
13
14
export default editor

admin.ts

1
import type {Access} from 'payload'
2
import { checkRole } from './checkRole'
3
4
const admin: Access = ({ req: { user } }) => {
5
if (user) {
6
if (checkRole(['admin'], user)) {
7
return true
8
}
9
}
10
11
return false
12
}
13
14
export default admin

4. Testing in the Admin UI

With your protectRoles hook in place and at least one admin user assigned, you can now verify that your role-based access logic works as expected.

  1. Create test users:
    1. nick+user@yourdomain.com (roles: user)
    2. nick+editor@yourdomain.com (roles: editor)

Use Auto-Login to Simulate Access

During development, you can enable auto-login to test different permission levels. In your payload.config.ts:

1
autoLogin: process.env.NEXT_PUBLIC_ENABLE_AUTOLOGIN === 'true' ? {
2
email: 'nick+user@midlowebdesign.com',
3
password: 'user',
4
} : false,

As you navigate to your admin panel, you'll see nothing changes because everyone has the same permissions. But now let's add the access rules.

5. Apply access rules in Collections

Here's an example for how you'd set access in a Users collection inside your collections/Users/config.ts file:

1
export const Users: CollectionConfig = {
2
slug: 'users',
3
access: {
4
create: editor,
5
read: user,
6
update: user,
7
delete: admin,
8
}

Now when we save and return to the admin panel, we'll only see the user we're logged into. I also cannot create or delete any users. You can test this across both the editor and admin roles.

6. Field-Level Access (Optional)

To restrict editing the roles field to admins only:

1
{
2
name: 'roles',
3
type: 'select',
4
hasMany: true,
5
saveToJWT: true,
6
options: [
7
{ label: 'Admin', value: 'admin' },
8
{ label: 'Editor', value: 'editor' },
9
{ label: 'User', value: 'user' },
10
],
11
hooks: {
12
beforeChange: [protectRoles],
13
// Setting field-level access control for admins only
14
},
15
access: {
16
update: ({ req: { user } }) => checkRole(['admin'], user as User),
17
},
18
}

7. Checking Auth on the Frontend (Next.js)

To setup permissions on the frontend, let's create page.tsx in our frontend directory (frontend)>page.tsx).

1
import {headers as getHeaders} from 'next/headers'
2
import {getPayload} from 'payload'
3
import config from '@/payload.config'
4
5
export default async function Home() {
6
const headers = await getHeaders()
7
const payload = await getPayload({config})
8
const {permissions, user} = await payload.auth({headers})
9
10
return <h1>
11
{user ? `You are authenticated as ${user.roles?.map(role => role).join(', ')}` : 'You are not logged in'}
12
</h1>
13
14
}

If we try returning to our server, we'll be met with the above messaging depending on our login status.

Summary

As you can see, it's relatively simple to set up role-based access control with Payload. Taking what you’ve learned from this video, you can easily add more roles to your list and start applying them across different collections.

To recap ...

  • Roles are defined with a select field and secured using a beforeChange hook.
  • Collection and field access are managed using reusable access control functions.
  • Frontend auth can be checked server-side using payload.auth().