Building Role-Based Authentication with Next.js and Prisma

How to easily create role-based authentication using Next-auth (Auth.js) and prisma adapter.

Building Role-Based Authentication with Next.js and Prisma

In this blog post, we’ll walk you through the step-by-step process of creating role-based authentication for your Next.js application using Next Auth and Prisma adapter. You’ll have a solid foundation to build flexible and scalable user access control systems by the end.

In the project, we’ll be using Next.js App Directory. As you know, after Next.js 13, we create API routes using the App directory and route files.

Let’s install the authentication library and start creating the auth API route.

npm install next-auth

To add Next Auth.js to the project create a file called route.ts in app/api/auth/[…nextauth] folder.

You can directly add your auth options in this file, but I prefer using a different folder to be able to reuse the options later.

Let’s create an auth.ts file in src/utils folder and give providers.

import { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";

export const authOptions: NextAuthOptions = {
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_ID!,
      clientSecret: process.env.GOOGLE_SECRET!,
    }),
  ],
};

If you want to add other providers such as GitHub, Facebook.

We are now ready to create a route handler and add these options. Open up the route file and add this.

app/api/auth/[…nextauth]/route.ts

import { authOptions } from "@/utils/auth";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

Now, we can sign in using a Google account. But we don’t save the users in a database. So let’s install Prisma and create our authentication schema.

npm install prisma @prisma/client @next-auth/prisma-adapter

to initialize it:

npx prisma init

Open schema.prisma file in prisma folder and add this code block:

datasource db {
  //You can use any database provider. It doesn't have to be PostgreSQL
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider        = "prisma-client-js"
}

model Account {
  id                String  @id @default(cuid())
  userId            String
  type              String
  provider          String
  providerAccountId String
  refresh_token     String? @db.Text
  access_token      String? @db.Text
  expires_at        Int?
  token_type        String?
  scope             String?
  id_token          String? @db.Text
  session_state     String?

  user User @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([provider, providerAccountId])
}

model Session {
  id           String   @id @default(cuid())
  sessionToken String   @unique
  userId       String
  expires      DateTime
  user         User     @relation(fields: [userId], references: [id], onDelete: Cascade)
}

model User {
  id            String    @id @default(cuid())
  name          String?
  email         String    @unique
  image         String?
  isAdmin       Boolean   @default(false)
  emailVerified DateTime?
  accounts      Account[]
  sessions      Session[]
}

model VerificationToken {
  identifier String
  token      String   @unique
  expires    DateTime

  @@unique([identifier, token])
}

We use This standard schema for the Prisma adapter; you can find it in the official documentation.

I’ve only added the isAdmin field in the user model to give a role for users. I use a boolean because my application has only two roles (admin/regular user). But if you want to, you can create more roles by changing that field.

This will create an SQL migration file and execute it:

npx prisma migrate dev

Let’s get back to the auth options and add our prisma adapter.

src/utils/auth.ts

const prisma = new PrismaClient();

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_ID!,
      clientSecret: process.env.GOOGLE_SECRET!,
    }),
  ],
};

From now on, the adapter will handle authentication and automatically add new users, sessions, and accounts into the database.

But when we try to react the user using sessions (useSession hook for the client side and getServerSession for the server side), it’ll return only, name,email and image. But we need isAdmin property for the authorization.

To do that, we should first find the user in the database and add its isAdmin value to the session. Let’s add the following code:

export const authOptions: NextAuthOptions = {
  adapter: PrismaAdapter(prisma),
  session: {
    strategy: 'jwt',
  },
  providers: [
      clientId: process.env.GOOGLE_ID!,
      clientSecret: process.env.GOOGLE_SECRET!,
    }),
  ],
  callbacks: {
    async session({ token, session }) {
      if (token) {
        session.user.isAdmin = token.isAdmin;
      }

      return session;
    },
    async jwt({ token }) {
      const dbUser = await prisma.user.findUnique({
        where: {
          email: token.email!,
        },
      });

      token.isAdmin = Boolean(dbUser?.isAdmin);

      return token;
    },
  },
};

In the jwt callback, we take the isAdmin value from the database and hide it in the JWT. And in the session callback, we take the token, find the isAdmin value that we’ve set, and add it into session. And know when we attempt to reach the session, it’ll return the isAdmin property along with other user properties.

If you have any problem at this point, try to clean the cache in the .next folder and cookies in the browser.