DEV Community

Rafaat Ahmed
Rafaat Ahmed

Posted on

Next-auth App Router Credentials - An Annotated Guide

This is an annotated guide on implementing Auth.js (NextAuth 5) on NextJS (app router). We will follow official steps outlined in respective docs.

We want to implement

  • a email+password based login system,
  • set custom login pages
  • protect routes, and
  • add roles (ie extend user objects for additional vaues)

Tldr: Git repo - https://github.com/kaizenworks/nextjs-authjs-example

NextJS Install

Source - https://nextjs.org/docs/getting-started/installation

Create project

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Project name and options

What is your project named? my-app
Would you like to use TypeScript? Yes
Would you like to use ESLint? Yes
Would you like to use Tailwind CSS? Yes
Would you like to use `src/` directory? Yes
Would you like to use App Router? (recommended) Yes
Would you like to customize the default import alias (@/*)? No
Enter fullscreen mode Exit fullscreen mode

On fresh install (ts version), every page inside /src/app will throw module not found errors on imports.
This is probably because in tsconfig.json, moduleResoultion is set to bundler. More here - https://github.com/vercel/next.js/discussions/41189

Setting moduleResoultion: "node" solves the issue.

Auth.js / NextAuth install

Source - https://authjs.dev/getting-started/installation

Package install

npm install next-auth@beta
Enter fullscreen mode Exit fullscreen mode

Setup enironment

npx auth secret
Enter fullscreen mode Exit fullscreen mode

Supposed to create a .env.local file with AUTH_SECRET= value, but didn't. So had to manullay save the outputted value to .env.local.

Create auth.js at the root of the project (ie /src)

//  path: ./src/auth.ts

import NextAuth from "next-auth"

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [],
})
Enter fullscreen mode Exit fullscreen mode

Set auth related route handlers

// path: ./src/app/api/auth/[...nextauth]/route.ts

import { handlers } from "@/auth" // Referring to the auth.ts we just created
export const { GET, POST } = handlers 
Enter fullscreen mode Exit fullscreen mode

Add middleware

//path: ./src/middleware.ts

export { auth as middleware } from "@/auth"
Enter fullscreen mode Exit fullscreen mode

I expected it would protect all the routes by default. But you would have to define guard rules separately. This version will keep the session values updated.

Credential (Email+Password) Authentication Setup

Source - https://authjs.dev/getting-started/authentication/credentials

Setup Credential Provider in auth.ts file

import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Credentials({
        name: "Email",
        credentials: {
            email: { label: "Email", type: "email " },
            password: { label: "Password", type: "password" },
        },
        authorize: async (credentials) => {

            let {email,password} = credentials;

            if(email==process.env.ADMIN_EMAIL
                && password==process.env.ADMIN_PASS) {
                    return {
                        id: '1',
                        name: 'Admin'
                    }
                }

            return null;
        },
      }),
  ],
})
Enter fullscreen mode Exit fullscreen mode

Define database lookup and password matching inside the authorize callback. I have used static env variables to keep things simple.

NextAuth uses a default user type, but you can extend it (more on that later)

Signin and Signout

Source - https://authjs.dev/getting-started/session-management/login

Create signin button


'use client'

import { signIn } from "next-auth/react"

export const SignInButton = ({children}:any) => {

    return (
        <button className="[redacted]." type="button" onClick={()=>signIn()}>
            {children}
        </button>
    )
}
Enter fullscreen mode Exit fullscreen mode

Clicking this button takes user to default sign in page.

Note there are two ways of importin signin/out functions

  • Your auth.js file or @/auth for server-side use
  • next-auth/react for client-side use

Define component render boundaries with use client and use server

Create signout button

Signout button is the same, but using signOut method.

Add buttons to Home


//.... imports

export default async function Home() {

    const session = await auth();

    return (

        ...

        <div>
        { session
            ? (
                <>
                    <h1>{session.user?.name}</h1>
                    <SignOutButton>Sign Out</SignOutButton>
                </>
            )
             : (
                <>
                    <h1>Guest</h1>
                    <SignInButton>Sign In</SignInButton>
                </>
              )}
        </div>

        ...

    )
}
Enter fullscreen mode Exit fullscreen mode

Protecting routes

Source - https://authjs.dev/getting-started/session-management/protecting

To demonstrate this, we will add another route /dashboard and make it protected (ie only visible to logged in users)

Add middleware with routh matcher config

export { auth as middleware } from "@/auth"

export const config = {
    matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
Enter fullscreen mode Exit fullscreen mode

This did not work out for me.

But defining it inside a custom auth function works:

import { auth } from "@/auth"

export default auth((req) => {
  if (!req.auth) {
    const url = new URL( "/login", req.url );
    // here "/login" is a custom login page, we havent defined it yet
    return Response.redirect(url)
  }
})
Enter fullscreen mode Exit fullscreen mode

Another way is to define in the page.tsx of the specific route, in our case /dashboard

import { auth, signIn } from "@/auth";

export default async function Dashboard() {

  const session = await auth();

  if (!session) return signIn();

  return (
    ...
  );
}

Enter fullscreen mode Exit fullscreen mode

Custom Login Page

Source - https://authjs.dev/getting-started/session-management/custom-pages

Define custom signIn path in auth.ts

import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"

export const { handlers, signIn, signOut, auth } = NextAuth({
  pages: {
    signIn: "/login",
  },
  ...
})
Enter fullscreen mode Exit fullscreen mode

Create a Login page and implement signin()

We will do this using server actions -

  1. create signin request action
  2. create signin form component (client side). Bind reqeuestSignIn using useFormState
  3. create login page on login/page.tsx that holds the form component

@/actions/request-sign-in.ts

"use server"
import { signIn } from "@/auth";
import { redirect } from "next/navigation";

type FormState = {
    error: string;
};

export async function requestSignIn(formState:FormState,formData: FormData) {
    try{
        const email = formData.get('email') as string;
        const password = formData.get('password') as string;
        await signIn("credentials", {email,password, redirect:false});
    } catch(error){
        return {error: "Invalid login"};
    }

    redirect('http://localhost:3000/');
}
Enter fullscreen mode Exit fullscreen mode


`

When called from useFormState the first param is the state and the second on is FormData. Got it mixed up.

Its weird that you have to pass reidirect or reidrectTo inside the data param in signIn.

But it throws a NEXT_REDIRECT error. Keeping redirect out of try/catch works somehow.

@/components/sign-in-form.client.tsx

`jsx
"use client"

import { requestSignIn } from "@/actions/request-sign-in";
import { useEffect } from "react";
import { useFormState } from "react-dom";

const initState = { error: '' };

export const SignInForm = () => {
const [fstate, action] = useFormState(requestSignIn, initState);

useEffect(() => {
    let { error } = fstate;
    if (error) alert(error);
}, [fstate])


return (

    <form
        action={action}
    >
        <label>
            Email
            <input name="email" type="email" />
        </label>
        <br />
        <label>
            Password
            <input name="password" type="password" />
        </label>
        <br />
        <button>Sign In</button>
    </form>
)
Enter fullscreen mode Exit fullscreen mode

}
`

@/app/login/page.tsx

`tsx
import { SignInForm } from "@/components/sign-in-form.client";

export default function SignInPage() {

return (
    <div className="flex flex-col gap-2">
        <SignInForm />
    </div>
)
Enter fullscreen mode Exit fullscreen mode

}

`

Session Data

Source : Extending Session - https://authjs.dev/guides/extending-the-session

Source: Role Based Access Example - https://authjs.dev/guides/role-based-access-control

@/auth.ts

`ts
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
...
authorize: async (credentials) => {

            let { email, password } = credentials;

            if (email == process.env.ADMIN_EMAIL
                && password == process.env.ADMIN_PASS) {
                return {
                    id: '1',
                    name: 'Admin',
                    role: 'staff'
                }
            }

            return null;
        },
    }),
],
callbacks: {
    jwt({ token, user }) {
        if (user) {
            token.role = user.role;
        }
        return token;
    },
    session({ session, token }) {
        session.user.role = token.role
        return session
    },
}
Enter fullscreen mode Exit fullscreen mode

})
`

We defined two callbacks here

jwt

During sign-in, the jwt callback exposes the user’s profile information coming from the provider. You can leverage this to add the user’s id to the JWT token.

Here will will add the user role to token data

session

This callback is called whenever a session is checked. (i.e. when invoking the /api/session endpoint, using useSession or getSession). The return value will be exposed to the client, so be careful what you return here! If you want to make anything available to the client which you've added to the token through the JWT callback, you have to explicitly return it here as well.

In our case we are exposing role in session.

Typescript error

When extending user with role field, there will be a type error in the callbacks.

Property role does not exist on type User | AdapterUser

Solution is to extend the User and Session tyoes from Next Auth

@/types/next-auth.d.ts

ts
import { DefaultUser } from 'next-auth';
declare module 'next-auth' {
interface Session {
user?: DefaultUser & { id: string; role: string };
}
interface User extends DefaultUser {
role: string;
}
}

Source - https://stackoverflow.com/questions/74425533/property-role-does-not-exist-on-type-user-adapteruser-in-nextauth

Additional resources -

  1. (Youtube) Next.js App Router Authenication - https://www.youtube.com/watch?v=DJvM2lSPn6w
  2. Practical overview of Nextjs forms and server actions - https://www.robinwieruch.de/next-forms/

Top comments (0)