Nextjs 14 roles and permissions (RBAC) : Step-by-Step Guide

Nextjs 14 roles and permissions (RBAC) : Step-by-Step Guide

Posted By

kamlesh paul

on

Dec 9, 2024

Table of contents

Introduction

Let’s start with implementing Nextjs 14 roles and permissions. This step-by-step guide will help you set up a strong system to manage who can access what in your app.

Shorter Overview

If you don’t want details this long article, you can check out our short article about this here.

Prerequisites

Before diving into the implementation of Next.js 14 roles and permissions, ensure you have the following prerequisites in place:

  1. Basic Knowledge of Next.js: Familiarity with Next.js and its core concepts.
  2. Node.js and npm/pnpm Installed: Ensure you have Node.js and npm or pnpm installed on your system.
  3. A PostgreSQL Database: Set up PostgreSQL for managing your application’s data.
  4. Drizzle ORM: Install and configure Drizzle ORM for database interactions.
  5. Auth.js (NextAuth): Set up NextAuth for authentication and session management.
  6. Shadcn UI: Install Shadcn UI for user interface components.
  7. Tailwind CSS: Set up Tailwind CSS for styling your application.

Database Structure

To manage roles and permissions in Next.js 14, you’ll need the following tables in your database:

  1. Users Table: Each user can have one role.
  2. Roles Table: Defines different roles that can be assigned to users.
  3. Permissions Table: Lists all permissions that can be assigned to roles.
  4. Role_Permissions Table: A pivot table that links roles to permissions.

Step 1 : Setup Nextjs application

  • Start by creating a new Next.js project using create-next-app:
npx create-next-app@latest my-app --typescript --tailwind --eslint
 
npx shadcn-ui@latest init

Use Shadcn UI installation guide for the latest script.

  • Install all necessary libraries:
pnpm add dotenv bcryptjs react-toastify zod zsa zsa-react tsx
 
pnpm add -D @types/bcryptjs    

Step 2 : Setup Drizzle ORM

  • Follow the guide here to set up Drizzle ORM with node-postgres.
pnpm add drizzle-orm pg
pnpm add -D drizzle-kit @types/pg 
  • Create a folder named server and place all server-side code there.
server/database/index.ts
import dotenv from 'dotenv';
dotenv.config();
 
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { pgTable, text, integer, serial } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
 
 
const pool = new Pool({
  host: process.env.POSTGRES_HOST,
  port: Number(process.env.POSTGRES_PORT),
  user: process.env.POSTGRES_USER,
  password: process.env.POSTGRES_PASSWORD,
  database: process.env.POSTGRES_DB,
});
 
export const rolesTable = pgTable('roles', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});
 
export const permissionsTable = pgTable('permissions', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
});
 
export const usersTable = pgTable('users', {
  id: serial('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  password: text('password').notNull(),
  role_id: integer('role_id').references(() => rolesTable.id).notNull(),
});
 
export const rolePermissionsTable = pgTable('role_permissions', {
  role_id: integer('role_id').references(() => rolesTable.id).notNull(),
  permission_id: integer('permission_id').references(() => permissionsTable.id).notNull(),
});
 
export const userRelations = relations(usersTable, ({ one }) => ({
  role: one(rolesTable, {
    fields: [usersTable.role_id],
    references: [rolesTable.id]
  })
}));
 
export const roleRelations = relations(rolesTable, ({ many }) => ({
  users: many(usersTable),
  permissions: many(permissionsTable)
}));
 
export const permissionRelations = relations(permissionsTable, ({ many }) => ({
  roles: many(rolePermissionsTable)
}))
 
 
export const db = drizzle(pool, {
  schema: {
    rolesTable,
    permissionsTable,
    usersTable,
    userRelations,
    roleRelations,
    permissionRelations
  }
});
 
 
export type IUser = typeof usersTable.$inferSelect;
export type IRole = typeof rolesTable.$inferSelect;
  • Create a .env file with the following content:
.env
POSTGRES_HOST="127.0.0.1"
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=databasename
  • Add drizzle.config.ts in root of you application
drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
import dotenv from 'dotenv';
 
dotenv.config();
 
 
if (
  !process.env.POSTGRES_HOST ||
  !process.env.POSTGRES_PORT ||
  !process.env.POSTGRES_USER ||
  !process.env.POSTGRES_PASSWORD ||
  !process.env.POSTGRES_DB
 
) {
  throw Error("Please add POSTGRES INFO");
}
 
const connectionString = `postgresql://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@${process.env.POSTGRES_HOST}:${process.env.POSTGRES_PORT}/${process.env.POSTGRES_DB}`;
 
/** @type { import("drizzle-kit").Config } */
export default defineConfig({
  schema: './server/database/index.ts',
  out: './drizzle',
  dialect: "postgresql",
  dbCredentials: {
    url: connectionString,
  },
  verbose: true,
  strict: true,
})
  • Update package.json scripts:
package.json
{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:drop": "drizzle-kit drop",
    "db:studio": "drizzle-kit studio"
  },
  "dependencies": {
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.1.1",
    "dotenv": "^16.4.5",
    "drizzle-orm": "^0.32.2",
    "lucide-react": "^0.424.0",
    "next": "14.2.5",
    "pg": "^8.12.0",
    "react": "^18",
    "react-dom": "^18",
    "tailwind-merge": "^2.4.0",
    "tailwindcss-animate": "^1.0.7"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/pg": "^8.11.6",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "drizzle-kit": "^0.23.2",
    "eslint": "^8",
    "eslint-config-next": "14.2.5",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}
  • Run the following commands to generate and apply migrations:
pnpm run db:generate
pnpm run db:migrate

Step 3: Setup Auth.js (NextAuth)

  • Install NextAuth:
pnpm add next-auth@beta
npx auth secret
  • Copy the content from .env.local to .env and delete .env.local.
.env
POSTGRES_HOST="127.0.0.1"
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=test
AUTH_SECRET="CbAnQXI39PoYI8M+7RVARQTLSlcblW/ck0VNMcQupVU=" # Added by `npx auth`. Read more: https://cli.authjs.dev
  • Create app/api/auth/[…nextauth]/page.ts:
app/api/auth/[...nextauth]/page.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers
  • Create auth.ts in the root of your project:
auth.ts
import NextAuth, { DefaultSession } from "next-auth"
import Credentials from "next-auth/providers/credentials"
import { comparePassword } from "@/utils/password"
import { getUserByEmailAndPassword } from "./server/queries/user"
import { User } from "next-auth";
 
export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Credentials({
      credentials: {
        email: {},
        password: {},
      },
      authorize: async (credentials) => {
        const email = credentials.email as string;
        const password = credentials.password as string;
 
        const user = await getUserByEmailAndPassword(email);
        if (!user) {
          throw new Error("User not found.");
        }
 
        const isPasswordValid = await comparePassword({ plainPassword: password, hashPassword: user.password });
        if (!isPasswordValid) {
          throw new Error("Invalid password.");
        }
 
        return {
          id: user.id.toString(),
          name: user.name,
          email: user.email,
          role_id: user.role_id,
        } as User;
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user?.role_id) {
        token.role_id = user.role_id
      }
      return token;
    },
    session({ session, token }) {
      return {
        ...session,
        user: {
          ...session.user,
          role_id: token.role_id as string,
        },
      };
    },
  },
});
  • now create utils folder on root of application and create this 3 files we will need this later:
constants.ts
export const permissionList = {
  POST_SHOW: 'post-list',
  POST_CREATE: 'post-create',
  POST_EDIT: 'post-edit',
  POST_DELETE: 'post-delete'
}
guard.ts
"use server"
 
import { getMyPermissions } from "@/server/queries/user";
import { cache } from "react";
 
export const hasPermission = cache(async (permissions: string[]) => {
  const myPermissions = await getMyPermissions();
  return permissions.every(permission =>
    myPermissions.some(x => x.permissionName === permission)
  );
});
password.ts
import bcrypt from 'bcryptjs';
 
export async function makePassword(plainPassword: string): Promise<string> {
  try {
    const salt = await bcrypt.genSalt(10);
    const hash = await bcrypt.hash(plainPassword, salt);
    return hash;
  } catch (err) {
    throw new Error('Error hashing password');
  }
}
 
export async function comparePassword({
  plainPassword,
  hashPassword
}: {
  plainPassword: string,
  hashPassword: string
}): Promise<boolean> {
  try {
    return await bcrypt.compare(plainPassword, hashPassword);
  } catch (err) {
    console.log(err);
    throw new Error('Error comparing password');
  }
}

now you will get this error

Property ‘role_id’ does not exist on type ‘User | AdapterUser’.

  • To fix this create types.d.ts in root of application:
types.d.ts
declare module "next-auth" {
  /**
   * The shape of the user object returned in the OAuth providers' `profile` callback,
   * or the second parameter of the `session` callback, when using a database.
   */
  interface User {
    id: number
    name:string
    email:string
    password:string
    role_id:number
  }
 
  interface Session {
    user: {
      role_id: number;
    } & DefaultSession["user"];
  }
}
 
// The `JWT` interface can be found in the `next-auth/jwt` submodule
import { JWT } from "next-auth/jwt"
 
declare module "next-auth/jwt" {
  /** Returned by the `jwt` callback and `auth`, when using JWT sessions */
  interface JWT {
    /** OpenID ID Token */
    idToken?: string
  }
}
  • now let’s add queries create dir queries on server folder and create file
server/queries/user.ts
import { eq } from "drizzle-orm"
import { db, permissionsTable, rolePermissionsTable, rolesTable, usersTable } from "../database"
import { auth } from "@/auth";
import { cache } from "react";
 
export const getRoles = cache(async () => {
  const result = await db.query.rolesTable.findMany();
  if (!result) throw Error('No Roles found.');
  return result;
})
 
export const getCurrentUser = cache(async () => {
  const session = await auth();
  const result = await db.query.usersTable.findFirst({
    where: eq(usersTable.email, session?.user.email),
    with: {
      role: true
    }
  })
  if (!result) {
    throw Error('No Users found.')
  };
  console.log({ result });
 
  return result;
})
 
export const getUserByEmailAndPassword = async (email: string) => {
  const result = await db.query.usersTable.findFirst({
    where: eq(usersTable.email, email)
  });
  if (!result) throw Error('No Users found.');
  return result;
}
 
 
export const getMyPermissions = cache(async () => {
  const session = await auth();
  if (!session?.user?.role_id) {
    throw new Error("Role id not found in session.");
  }
 
  const permissions = await db
    .select({
      permissionName: permissionsTable.name
    })
    .from(rolePermissionsTable)
    .innerJoin(permissionsTable, eq(rolePermissionsTable.permission_id, permissionsTable.id))
    .where(eq(rolePermissionsTable.role_id, session.user.role_id));
 
  return permissions;
});

Step 4 : Setup Ui for Role-Based Access Control (RBAC)

  • Create a wrapper component to use the client-side library.
components/Wrapper.tsx
"use client"
 
import 'react-toastify/dist/ReactToastify.css';
import { ToastContainer } from 'react-toastify';
import { PropsWithChildren, ReactPropTypes } from 'react';
 
export default function Wrapper({
  children
}:PropsWithChildren){
  return (
    <>
      {children}
      <ToastContainer />
    </>
  )
 
}
  • Now, let’s create the login and register pages.
app/(auth)/login/page.tsx
import Link from 'next/link';
import LoginForm from './form';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
 
export default async function SignInPage() {
  const session = await auth();
  if (session) {
    redirect('/')
  }
 
  return (
    <div className="flex items-center justify-center min-h-screen bg-gray-100">
      <div className="w-full max-w-md p-8 bg-white shadow-md rounded-lg">
        <h1 className="text-2xl font-bold mb-6 text-center">Login</h1>
        <LoginForm />
        <div className="mt-4 text-center">
          <p className="text-gray-600">
            {"Don't have an account?"} {''}
            <Link href="/register" className="text-blue-600 hover:underline">
              register
            </Link>
          </p>
        </div>
      </div>
    </div>
  );
}
app/(auth)/login/login.tsx
"use client";
 
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { loginAction } from "@/server/actions/user.action";
import { FormEvent } from "react";
import { useServerAction } from "zsa-react";
 
export default function LoginForm() {
 
  const { isPending, execute, error } = useServerAction(loginAction);
 
  const handleLogin = async (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault()
    const form = event.currentTarget
    const formData = new FormData(form)
    const [data, error] = await execute(formData)
    console.log(data, error)
  }
 
  return (
    <form
      className="space-y-4"
      onSubmit={handleLogin}
    >
 
      {error?.code === 'ERROR' && (
        <div className="p-4 mb-4 text-sm text-red-700 bg-red-100 rounded-lg" role="alert">
          <span className="font-medium">Error!</span> {error.message}
        </div>
      )}
 
      <label className="block">
        <span className="text-gray-700">Email</span>
        <Input
          name="email"
          type="email"
          placeholder="Email address"
 
        />
        {error?.formattedErrors?.email && (<p className="text-red-500">{error?.formattedErrors?.email?._errors}</p>)}
      </label>
 
      <label className="block">
        <span className="text-gray-700">Password</span>
        <Input
          name="password"
          type="password"
          placeholder="Email address"
 
        />
        {error?.formattedErrors?.password && (<p className="text-red-500">{error?.formattedErrors?.password?._errors}</p>)}
 
      </label>
 
      <Button
        disabled={isPending}
        type="submit"
        className='w-full'
      >
        {isPending ? 'Sign In...' : 'Sign In'}
      </Button>
 
    </form>
  )
}
  • Now, let’s add all ShadCN components:
pnpm dlx shadcn-ui@latest add button 
pnpm dlx shadcn-ui@latest add input
pnpm dlx shadcn-ui@latest add dropdown-menu
pnpm dlx shadcn-ui@latest add sheet
  • Next, let’s add all actions:
server/actions/user.action.ts
"use server"
 
import bcrypt from 'bcryptjs';
import { createServerAction } from "zsa"
import z from "zod"
import { db, usersTable } from "../database";
import { eq } from "drizzle-orm";
import { comparePassword, makePassword } from "@/utils/password";
import { signIn, signOut } from "@/auth";
import { AuthError } from "next-auth";
 
function isRedirectError(error: Error & { digest?: string }) {
  return !!error.digest?.startsWith("NEXT_REDIRECT")
}
 
export const loginAction = createServerAction()
  .input(z.object({
    email: z.string().min(3).email(),
    password: z.string().min(3)
  }), {
    type: "formData",
  })
  .handler(async ({ input }) => {
 
    const user = await db.query.usersTable.findFirst({
      where: (eq(usersTable.email, input.email))
    })
 
    if (!user) {
      throw new Error('User not found.');
    }
 
    if (!await bcrypt.compare(input.password, user.password)) {
      throw new Error('User not found.');
    }
 
 
    try {
      await signIn("credentials", {
        email: input.email,
        password: input.password,
        redirectTo: "/"
      });
     } catch (error: any) {
       if (isRedirectError(error)) throw error; //https://github.com/nextauthjs/next-auth/discussions/9389
 
       if (error instanceof AuthError) {
         return {
           errors: undefined,
           message: error.cause?.err?.message
         }
       }
     }
 
 
    return {
      success: true,
      data: user
    };
  });
 
export const registerAction = createServerAction()
  .input(z.object({
    name: z.string().min(3),
    email: z.string().min(3).email(),
    password: z.string().min(3),
    role_id: z.string().min(1)
  }), {
    type: "formData",
  })
  .handler(async ({ input }) => {
 
    let user = await db.query.usersTable.findFirst({
      where: (eq(usersTable.email, input.email))
    })
    if (user) {
      throw new Error('User Already exists.');
    }
 
    const res = await db.insert(usersTable).values({
      email: input.email,
      name: input.name,
      password: await makePassword(input.password),
      role_id:parseInt(input.role_id)
    }).returning();
 
 
    if(res[0]){
      user = res[0];
    }
 
    return {
      success: true,
      data: user
    };
  });
 
 
export const logout = async () => {
  await signOut();
}
  • At this point, you should get a login page like this:

login-page-nextjs-14-roles-and-permissions-step-by-step-guide.webp

  • Now, let’s build the seeder so we can log in.

In package.json, add this line:

"db:seed": "tsx ./server/database/seed/index.ts"
  • Create the seed file at server/database/seed/index.ts:
server/database/seed/index.ts
import pg from "pg";
import dotenv from 'dotenv';
import { drizzle } from "drizzle-orm/node-postgres";
import * as Schema from "../../database";
import { makePassword } from "@/utils/password";
import { sql } from "drizzle-orm";
dotenv.config();
 
export const client = new pg.Client({
  host: process.env.POSTGRES_HOST,
  port: Number(process.env.POSTGRES_PORT),
  user: process.env.POSTGRES_USER,
  password: process.env.POSTGRES_PASSWORD,
  database: process.env.POSTGRES_DB,
});
 
async function main() {
  try {
    await client.connect();
    const db = drizzle(client, {
      schema: Schema
    });
 
    await db.execute(sql`TRUNCATE TABLE role_permissions CASCADE`);
    await db.execute(sql`TRUNCATE TABLE users CASCADE`);
    await db.execute(sql`TRUNCATE TABLE roles CASCADE`);
    await db.execute(sql`TRUNCATE TABLE permissions CASCADE`);
    await db.execute(sql`ALTER SEQUENCE roles_id_seq RESTART WITH 1`);
    await db.execute(sql`ALTER SEQUENCE permissions_id_seq RESTART WITH 1`);
    await db.execute(sql`ALTER SEQUENCE users_id_seq RESTART WITH 1`);
 
 
    // Insert roles
    const roles = await db.insert(Schema.rolesTable).values([
      { name: 'admin' },
      { name: 'editor' },
      { name: 'user' },
    ]).returning();
 
    // Insert permissions
    const permissions = await db.insert(Schema.permissionsTable).values([
      { name: 'post-list' },
      { name: 'post-create' },
      { name: 'post-edit' },
      { name: 'post-delete' }
    ]).returning();
 
    const getRoleId = (roleName: string) => {
      const role = roles.find(role => role.name === roleName);
      if (!role) {
        throw new Error(`Role ${roleName} not found`);
      }
      return role.id;
    };
 
    const getPermissionId = (permissionName: string) => {
      const permission = permissions.find(permission => permission.name === permissionName);
      if (!permission) {
        throw new Error(`Permission ${permissionName} not found`);
      }
      return permission.id;
    };
 
    const rolePermissions = [
      // Admin has all permissions
      {
        role_id: getRoleId('admin'),
        permission_id: getPermissionId('post-list')
      },
      {
        role_id: getRoleId('admin'),
        permission_id: getPermissionId('post-create')
      },
      {
        role_id: getRoleId('admin'),
        permission_id: getPermissionId('post-edit')
      },
      {
        role_id: getRoleId('admin'),
        permission_id: getPermissionId('post-delete')
      },
 
      // Editor has all permissions except delete
      {
        role_id: getRoleId('editor'),
        permission_id: getPermissionId('post-create')
      },
      {
        role_id: getRoleId('editor'),
        permission_id: getPermissionId('post-edit')
      },
 
      // User has only view permission
      {
        role_id: getRoleId('user'),
        permission_id: getPermissionId('post-list')
      }
    ];
 
    await db.insert(Schema.rolePermissionsTable).values(rolePermissions);
    await db.insert(Schema.usersTable).values([
      {
        name: 'Admin',
        email: 'admin@example.com',
        password: await makePassword('password'),
        role_id: getRoleId('admin')
      },
      {
        name: 'Editor',
        email: 'editor@example.com',
        password: await makePassword('password'),
        role_id: getRoleId('editor')
      },
      {
        name: 'User',
        email: 'user@example.com',
        password: await makePassword('password'),
        role_id: getRoleId('user')
      }
    ]);
 
    console.log("Seeding completed successfully!");
 
  } catch (error) {
    console.error("Error during seeding:", error);
  } finally {
    await client.end();
  }
}
 
main();
  • Now run:
pnpm db:seed

Next, let’s update the login functionality and home page. First, delete page.tsx and create a folder called (dashboard) for the protected group route. Add a layout for the look and feel of the admin panel and to protect pages and UI:

app/(dashboard)/layout.tsx
import Link from "next/link"
import {
  CircleUser,
  Coffee,
  Home,
  Menu,
  Users,
} from "lucide-react"
 
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
import { auth } from "@/auth"
import { redirect } from "next/navigation"
import Logout from "./logout"
import Guard from "@/components/server/Guard"
import { permissionList } from "@/utils/constants"
 
 
const Nav = () =>
  <nav className="grid items-start px-2 text-sm font-medium lg:px-4">
    <Link
      href="/"
      className="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary"
    >
      <Home className="h-4 w-4" />
      Dashboard
    </Link>
 
    <Guard
      permissions={[permissionList.POST_SHOW]}
    >
      <Link
        href="/users"
        className="flex items-center gap-3 rounded-lg px-3 py-2 text-muted-foreground transition-all hover:text-primary"
      >
        <Users className="h-4 w-4" />
        Users
      </Link>
    </Guard>
  </nav>
 
export default async function DashboardLayout(
  { children }:
    Readonly<{
      children: React.ReactNode;
    }>) {
 
  const session = await auth();
  if (!session) {
    redirect('/login')
  }
  return (
    <div className="grid min-h-screen w-full md:grid-cols-[220px_1fr] lg:grid-cols-[280px_1fr]">
      <div className="hidden border-r bg-muted/40 md:block">
        <div className="flex h-full max-h-screen flex-col gap-2">
          <div className="flex h-14 items-center border-b px-4 lg:h-[60px] lg:px-6">
            <Link href="/" className="flex items-center gap-2 font-semibold">
              <Coffee className="h-6 w-6" />
              <span className="">Coding Tricks</span>
            </Link>
          </div>
          <div className="flex-1">
            <Nav />
          </div>
 
        </div>
      </div>
      <div className="flex flex-col">
        <header className="flex h-14 items-center gap-4 border-b bg-muted/40 px-4 lg:h-[60px] lg:px-6">
          <Sheet>
            <SheetTrigger asChild>
              <Button
                variant="outline"
                size="icon"
                className="shrink-0 md:hidden"
              >
                <Menu className="h-5 w-5" />
                <span className="sr-only">Toggle navigation menu</span>
              </Button>
            </SheetTrigger>
 
            <SheetContent side="left" className="flex flex-col">
              <div className="mt-10">
                <Nav />
              </div>
            </SheetContent>
          </Sheet>
          <div className="w-full flex-1">
 
          </div>
          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button variant="secondary" size="icon" className="rounded-full">
                <CircleUser className="h-5 w-5" />
                <span className="sr-only">Toggle user menu</span>
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuLabel>{session.user?.email}</DropdownMenuLabel>
              <DropdownMenuSeparator />
              <Logout />
            </DropdownMenuContent>
          </DropdownMenu>
        </header>
        {children}
      </div>
    </div>
  )
}
  • Create the logout component since it needs to be a client component:
app/(dashboard)/logout.tsx
"use client"
 
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { logout } from "@/server/actions/user.action"
 
export default function Logout() {
 
 
  return <DropdownMenuItem
    onClick={() => logout()}
  >Logout</DropdownMenuItem>
}
  • Create a Guard component to check permissions and show/hide UI elements accordingly:
components/server/Guard.tsx
"use server";
 
import { getMyPermissions } from "@/server/queries/user";
import { ReactNode } from "react";
 
interface GuardProps {
  permissions: string[];
  children: ReactNode;
}
 
export default async function Guard({ permissions, children }: GuardProps) {
  const myPermissions = await getMyPermissions();
 
  const hasPermissions = permissions.every(permission =>
    myPermissions.some(x => x.permissionName === permission)
  );
 
  if (!hasPermissions) {
    return null;
  }
 
  return (
    <>
      {children}
    </>
  );
}
  • Add a users page that connects to the users tab from the sidebar:
app/(dashboard)/users/page.tsx
import { permissionList } from '@/utils/constants'
import { hasPermission } from '@/utils/guard'
import { redirect } from 'next/navigation';
import React from 'react'
 
export default async function UserPage() {
 
  const permission = await hasPermission([permissionList.POST_SHOW]);
  if (!permission) {
    return redirect('/')
  }
 
  return (
    <div>You have User page access</div>
  )
}

dashboard nextjs-14-roles-and-permissions-step-by-step-guide.webp

When logged in as admin, you should see all permissions. This button UI is protected by the Guard.tsx component.

  • Now log in with editor credentials:
email : editor@example.com
passwod : password

editor dashboard nextjs-14-roles-and-permissions-step-by-step-guide.webp

You will see that the show and delete permissions are not displayed, and the sidebar users tab is also removed. Check if the /users page is accessible. Since the editor doesn’t have the post-show permission, it should redirect to the home page:

  const permission = await hasPermission([permissionList.POST_SHOW]);
  if (!permission) {
    return redirect('/')
  }

You can add as many permissions as you want to allow.

Conclusion

You’ve successfully implemented a role-based access control (RBAC) system in your Next.js 14 application using Drizzle ORM and NextAuth. This setup allows you to manage user roles and permissions effectively, ensuring that only authorized users can access specific parts of your application.

You can refer to this GitHub repository for the full code: Next.js Role and Permission.

Share this article

65 views