Next.js Auth Simplified: NextAuth with Role-Based Access

Shivam AshtikarShivam Ashtikar
15 min read

In this article, we’ll explore NextAuth.js, a powerful authentication library built specifically for Next.js applications, and understand how to implement Role-Based Access Control (RBAC) using it.

Let’s begin with the basics: What is authentication?
Authentication is a critical part of any web application. It ensures that only legitimate users can access certain features or areas, helping to protect data and improve security.

So, what exactly is NextAuth.js?
NextAuth.js is a flexible and easy-to-integrate open-source authentication solution for Next.js projects. It simplifies sign-in flows, session management, and route protection with minimal configuration. You can check out the official NextAuth.js documentation to understand its full set of features and why it’s a great choice for authentication in modern web apps.

Important Note: While NextAuth.js handles login, logout, and session management, it does not provide a built-in registration flow.
If your application requires user sign-up, you'll need to implement your own registration logic, typically using an API route and storing user data in a database before integrating it with NextAuth.

Now, let’s move to another key concept: What is RBAC (Role-Based Access Control)?
RBAC is a strategy for managing user permissions based on their assigned roles. For example, an admin might have access to certain dashboards or features that a regular user cannot see. With RBAC, you can easily define and enforce these kinds of access rules in your application.

Setting Up Your Next.js Project

Before we dive into authentication, let's setup a basic Next.js project. If you haven't already, install the Next.js CLI and create a new project:

npx create-next-app@latest my-nextjs-app
cd my-nextjs-app
npm install

Installing NextAuth.js

Install the next-auth.js by typing the following command

npm install next-auth

Understanding NextAuth.js Configuration

Before diving into the configuration of NextAuth.js, it’s important to understand the building blocks involved. This will make the setup process clearer and smoother.
Below is a diagram that outlines the core flow of how NextAuth.js works with TypeScript:

1. next-auth.d.ts (TypeScript only)

If you're using TypeScript, you need to create a next-auth.d.ts file to define the types for your Session, User, and JWT.

Note: This step is not required if you're using plain JavaScript.

2. NextAuth Options

The core of your authentication setup is the NextAuth options object, where you define how users can log in and how sessions are managed.

  1. Providers

This is where you specify which login methods are supported, using the providers array.
You can add OAuth providers like Google, GitHub, etc., or use the Credentials provider for custom email/password login.

Credentials Provider:
To use the Credentials provider, you must:

  • Define the fields users will input (e.g., email, password)

  • Implement the authorize function to verify those credentials and return a user object if valid

  1. Callbacks

Callbacks are like middleware functions that allow you to customize the authentication flow.

  • jwt callback: Used to attach extra data (like user ID, role, etc.) into the token stored server-side

  • session callback: Exposes that token data to the client via useSession()

💡 Think of it like this:

  • jwt = server-side memory

  • session = client-side memory
    This is crucial when you want to implement Role-Based Access Control (RBAC) by securely passing user roles from your backend to the frontend.

  1. Pages

The pages option allows you to override default authentication pages such as:

  • signIn

  • signOut

  • error

You can point these to your own custom components for a fully branded user experience.

4. Session Strategy

Here, you define how session data is stored and how long it's valid.

  • strategy:

    • "jwt": Sessions are stored in the token (stateless; great for serverless apps)

    • "database": Sessions are saved in a DB (useful for persistent login control)

  • maxAge: Determines how long the session remains valid (e.g., 60 60 24 for 24 hours)

  1. Secret

The secret is a critical part of the configuration. It’s used to sign and encrypt tokens and cookies.

  • In development, a secret is auto-generated.

  • In production, you must define a strong NEXTAUTH_SECRET in your .env file to ensure security and prevent session issues.

    Example: Now that you understand the structure of the options object, let’s look at a simple example of how to set up the Credentials provider:

      import CredentialsProvider from "next-auth/providers/credentials";
    
      export const options = {
        providers: [
          CredentialsProvider({
            name: "Credentials",
            credentials: {
              email: { label: "Email", type: "email" },
              password: { label: "Password", type: "password" }
            },
            authorize: async (credentials) => {
              // Replace this with your real DB logic
              const user = await getUserFromDB(credentials.email, credentials.password);
              if (user) {
                return { id: user.id, name: user.name, email: user.email, role: user.role };
              }
              return null;
            }
          })
        ],
        callbacks: {
          async jwt({ token, user }) {
            if (user) {
              token.role = user.role; // Store role in token
            }
            return token;
          },
          async session({ session, token }) {
            session.user.role = token.role; // Make role available on client
            return session;
          }
        },
        pages: {
          signIn: "/auth/signin",
          error: "/auth/error"
        },
        session: {
          strategy: "jwt",
          maxAge: 60 * 60 * 24
        },
        secret: process.env.NEXTAUTH_SECRET
      };
    

3. […nextauth]

As shown in the diagram, the [...nextauth] route is a special API route where the core authentication logic is implemented using NextAuth.js. In a Next.js app using the App Router, this folder is typically created at /app/api/auth/[...nextauth]/route.js (or .ts if you're using TypeScript).

Inside the route.js file, we create a handler function using NextAuth(options), where the options object contains the main authentication configuration—such as providers, callbacks, custom pages, session strategy, and the secret key. This handler is then exported to handle both GET and POST requests:

import NextAuth from "next-auth";
import { options } from "./options";

const handler = NextAuth(options);

export { handler as GET, handler as POST };

This setup is standard in almost all NextAuth.js implementations. Since this is a dynamic catch-all route, Next.js automatically matches any subpath like /api/auth/callback or /api/auth/signin. If the request is a GET, it typically returns the relevant UI (like the sign-in page or callback). If it’s a POST, it handles actions like logging in the user, creating a session, or other authentication-related logic.

4. Register

While NextAuth.js handles login, logout, and session management, it does not include a built-in registration system.
If your app needs sign-up functionality, you’ll need to create a custom registration flow using your own API route and database logic before allowing users to log in via NextAuth.

5. Middleware

NextAuth.js middleware is used to protect specific routes or your entire application by requiring users to be authenticated before accessing certain pages. The middleware runs before a page loads and checks if the user is logged in. If not, it automatically redirects them to the sign-in page. This is especially useful for routes like /dashboard or admin pages that should only be visible to authenticated users.

How It Works:

  • You import withAuth from "next-auth/middleware".

  • This middleware checks if the user is logged in before letting them access the page.

  • If they're not authenticated, they’ll be redirected to the sign-in page.

Prerequisite:

You must set the same NEXTAUTH_SECRET in both your NextAuth config and middleware for it to work properly.

Basic Usage:

To protect your entire app:

export { default } from "next-auth/middleware"

To protect only certain routes (e.g., /dashboard):

export { default } from "next-auth/middleware"

export const config = {
  matcher: ["/dashboard"],
}

Note: Use the matcher option to selectively apply authentication to only specific routes.

Implementing RBAC with NextAuth.js

Now that we’ve covered the foundations of authentication with NextAuth.js, let’s bring it all together by building a hands-on project to demonstrate how Role-Based Access Control (RBAC) works in a real-world scenario.

In this demo, we'll use GitHub and Google login with NextAuth.js and create a few simple routes to give you a clear understanding of how authentication and role-based access control (RBAC) work in practice.

  • / → Home page (accessible to everyone)

  • /public → Public page (accessible to everyone)

  • /client-session → Page using useSession() to access session data on the client

  • /server-session → Page using getServerSession() to access session data on the server

  • /admin → Protected page accessible only to hardcoded admin users

  • /denied → Redirect page for users without access permissions.

We'll also:

  • Show how to log in and log out using Google and GitHub.

  • Protect routes based on roles.

  • Use middleware for route-level protection.

  • Demonstrate both client-side and server-side session checks.

By the end of this walkthrough, you’ll have a solid understanding of how authentication and role-based access work together in a Next.js app using NextAuth.

Project Setup

You can either continue in an existing Next.js project or create a new one for this walkthrough.
For clarity and a clean setup, I recommend creating a new project. Use the standard Next.js commands to initialize your app. For this guide, we’ll be using JavaScript, not TypeScript.

Once your project is ready, navigate to the app directory and create the following pages:

  • Public

  • ClientMember (Add the 'use client' directive at the top of the file.)

  • ServerMember

  • Admin

  • Denied

In each of these pages, you can type rafce (if you're using an extension like ES7+ React Snippets) to quickly generate a React functional component boilerplate. You’re free to add styling if you like, but I won’t focus on design in this guide.

Here’s an example of a simple Denied.jsx page using Tailwind CSS to highlight the text in red:

import React from "react";

const Denied = () => {
  return (
    <div>
      <h1 className="text-red-400">Denied</h1>
    </div>
  );
};

export default Denied;

Now, create a components folder in the root of your project. Inside it, add a file called Nav.jsx and include the following code to create a basic navigation bar:

import Link from "next/link";
const Nav = async () => {
  return (
    <header className="bg-gray-600 text-gray-100">
      <nav className="flex justify-between items-center w-full px-10 py-4">
        <div>My Site</div>
        <div className="flex gap-10">
          <Link href="/">Home</Link>
          <Link href="/Admin">Admin</Link>
          <Link href="/ClientMember">Client Member</Link>
          <Link href="/ServerMember">Server Member</Link>
          <Link href="/Public">Public</Link>
        </div>
      </nav>
    </header>
  );
};

export default Nav;

Next, import the Nav component into your layout.js file to make it visible across all pages. Here's how your layout.js should look:

import Nav from "@/components/Nav";
import "./globals.css";
export const metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body className="bg-gray-100">
        <Nav />
          <div className="m-2">{children}</div>
      </body>
    </html>
  );
}

At this point, your project is set up with the basic pages and navigation.

Now, navigate into your project directory and start the development server by running the following commands:

cd your_project
npm run dev

After setting up everything, your website will look something like this:

Creating NextAuth.js API – options.js and route.js

After setting up the project, the next step is to configure NextAuth.js. If you haven’t already installed it, run the following command:

npm install next-auth

Now, to configure NextAuth, we need to define an options object. Some developers prefer placing this file in a lib folder at the root of the project, while others keep it closer to the API logic inside /api/auth/[...nextauth]. In this article, I’ll follow the second approach.

If you're following along, navigate to your app folder and create the following folder structure:

app
 └── api
     └── auth
         └── [...nextauth]
             ├── route.js
             └── option.js

We’ll start by working inside the option.js file.

In option.js, we configure how authentication works in our app using the options object. For this project, we’re using Google and GitHub as OAuth providers. Each provider includes a profile function that assigns a role to the user based on their email—for example, if the email is "admin@gmail.com", the role is set to "admin"; otherwise, a default role is assigned.

These roles are then passed securely through the authentication flow using NextAuth callbacks:

  • The jwt callback stores the role in the token on the server side.

  • The session callback makes the role accessible on the client side via useSession().

We also define a secret, which is essential for signing and encrypting tokens and session data securely.

Here’s how the option.js file looks:

import GitHubProvider from "next-auth/providers/github";
import GoogleProvider from "next-auth/providers/google";

export const options = {
  providers: [
    GitHubProvider({
      profile(profile) {
        console.log("Profile GitHub: ", profile);

        let userRole = "GitHub User";
        // Assign "admin" role to specific email; replace this with a valid admin email
        if (profile?.email === "admin@gmail.com") {
          userRole = "admin";
        }

        return {
          ...profile,
          role: userRole,
        };
      },
      clientId: process.env.GITHUB_ID,
      clientSecret: process.env.GITHUB_Secret,
    }),

    GoogleProvider({
      profile(profile) {
        console.log("Profile Google: ", profile);

        let userRole = "Google User";
         // Assign "admin" role to specific email; replace this with a valid admin email
        if (profile?.email === "admin@gmail.com") {
          userRole = "admin";
        }

        return {
          ...profile,
          id: profile.sub,
          role: userRole,
        };
      },
      clientId: process.env.GOOGLE_ID,
      clientSecret: process.env.GOOGLE_Secret,
    }),
  ],

  callbacks: {
    async jwt({ token, user }) {
      if (user) token.role = user.role;
      return token;
    },
    async session({ session, token }) {
      if (session?.user) session.user.role = token.role;
      return session;
    },
  },

  secret: process.env.NEXTAUTH_SECRET,
};

Note: You can get your GitHub and Google client IDs and secrets from their respective developer portals after creating an OAuth application.

You might notice that the pages option is not included here. That’s because we’re using the default authentication pages provided by NextAuth.js, which already handle sign-in, sign-out, and error states. These built-in pages offer basic functionality and are useful for quick setups. However, if you'd like a fully custom UI, you can define the pages object in options.js and point each route (like signIn, error, etc.) to your own components.

Once option.js is configured, open route.js in the same [...nextauth] folder and paste the following:

import NextAuth from "next-auth";
import { options } from "./options";

const handler = NextAuth(options);

export { handler as GET, handler as POST };

This code initializes the NextAuth handler using the options we just defined and exports it to handle both GET and POST requests. This allows NextAuth to manage login pages, callbacks, sessions, and other authentication logic automatically.

Login and Logout Functionality

Once we’ve configured the NextAuth API, the next step is to implement login and logout functionality.

In the Nav component, this is achieved using conditional rendering based on the user's session status. Here's how the full code for the Nav component looks:

import Link from "next/link";
import { getServerSession } from "next-auth";
import { options } from "@/app/api/auth/[...nextauth]/options";

const Nav = async () => {
  const session = await getServerSession(options);
  return (
    <header className="bg-gray-600 text-gray-100">
      <nav className="flex justify-between items-center w-full px-10 py-4">
        <div>My Site</div>
        <div className="flex gap-10">
          <Link href="/">Home</Link>
          <Link href="/Admin">Admin</Link>
          <Link href="/ClientMember">Client Member</Link>
          <Link href="/ServerMember">Server Member</Link>
          <Link href="/Public">Public</Link>
          {session ? (
            <Link href={"/api/auth/signout?callbackUrl=/"}> Logout </Link>
          ) : (
            <Link href={"/api/auth/signin"}> Sign In </Link>
          )}
        </div>
      </nav>
    </header>
  );
};

export default Nav;

We use getServerSession(options) to check whether the user is currently logged in by retrieving their session from the server.

  • If a session exists, the "Logout" link is shown. This points to /api/auth/signout with a callbackUrl=/, which logs the user out and redirects them to the homepage.

  • If no session is found, the "Sign In" link is displayed, sending users to the default NextAuth login page.

This dynamic rendering ensures users see the appropriate action—Sign In or Logout—based on their authentication status.

After correctly implementing this, you should see a login page where GitHub and Google providers are displayed, and logging in with either should work as expected.

Protecting Pages

1. Server-side Protection (ServerMember Page)

When working with server components in Next.js, you may need to restrict access to specific pages unless the user is authenticated.

To implement this:

  • Use getServerSession() from NextAuth to check for a session before rendering the page.

  • If no session is found, use redirect() to send the user to the sign-in page.

  • If authenticated, render the component and display user info.

import React from "react";
import { getServerSession } from "next-auth";
import { options } from "@/app/api/auth/[...nextauth]/options";
import { redirect } from "next/navigation";
const Member = async () => {
  const session = await getServerSession(options);

  if (!session) {
    redirect("/api/auth/signin?callbackUrl=/Member");
  }

  return (
    <div>
      <h1>Member Server session</h1>
      <p>{session?.user?.email}</p>
      <p>{session?.user?.role}</p>
    </div>
  );
};

export default Member;

What This Does:

  • getServerSession(options) checks for a valid session on the server.

  • If no session exists, the user is redirected to /api/auth/signin?callbackUrl=/Member.

  • If a session exists, the page renders and displays the user's email and role.

2. Client-side Protection (ClientMember Page)

Some pages require client-side rendering, especially when using hooks, interactive UI, or browser-specific features. You still want to ensure only authenticated users can access them.

In such cases, use the useSession() hook with the required: true flag.

"use client";
import { useSession } from "next-auth/react";
import { redirect } from "next/navigation";

const Member = () => {
  const { data: session } = useSession({
    required: true,
    onUnauthenticated() {
      redirect("/api/auth/signin?callbackUrl=/Member");
    },
  });

  return (
    <div>
      <h1>Member Client session</h1>
      <p>{session?.user?.email}</p>
      <p>{session?.user?.role}</p>
    </div>
  );
};

export default Member;

Explanation:

  • "use client" makes this a client component.

  • useSession() checks for an active session.

  • If the user is unauthenticated, onUnauthenticated() triggers a redirect to the login page.

  • If authenticated, the component renders and displays user details like email and role.

Protecting Routes with Middleware (RBAC)

Apart from server-side and client-side protection, Next.js Middleware offers a powerful third way to secure your application routes. Middleware runs before a page is rendered, making it perfect for guarding routes—even static ones—based on authentication and user roles.

In this example, we’ll use middleware to restrict access to the /Admin page so that only users with the admin role can access it. Others will be redirected to a /denied page.

import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";

export default withAuth(
  function middleware(req) {
    if (
      req.nextUrl.pathname.startsWith("/Admin") &&
      req.nextauth.token.role !== "admin"
    ) {
      return NextResponse.rewrite(new URL("/denied", req.url));
    }
  },
  {
    callbacks: {
      authorized: ({ token }) => !!token, // Only proceed if token exists (i.e., user is logged in)
    },
  }
);

export const config = {
  matcher: ["/Admin"], // You can add more protected routes here
};

What’s Happening Here?

  • The withAuth() helper wraps the middleware and gives you access to the user’s session token.

  • Inside the middleware function, we check:

    • If the user is trying to access /Admin

    • And if their role is not "admin"

  • If both conditions are met, the request is redirected to /denied.

  • The authorized callback ensures this logic only runs for authenticated users.

  • The matcher defines which route(s) the middleware should apply to—in this case, only /Admin.

After adding this middleware, visiting /Admin will only succeed if the user is authenticated and has an "admin" role. Others will be shown the denied page.

This wraps up the article on setting up authentication and role-based access control using NextAuth.js. I’ve covered everything from basic setup to protecting routes based on user roles.
This is my first time writing an article, so I hope it was clear and helpful.
Thanks for reading—and feel free to share any feedback!

4
Subscribe to my newsletter

Read articles from Shivam Ashtikar directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shivam Ashtikar
Shivam Ashtikar