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
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
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
Setup enironment
npx auth secret
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: [],
})
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
Add middleware
//path: ./src/middleware.ts
export { auth as middleware } from "@/auth"
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;
},
}),
],
})
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>
)
}
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>
...
)
}
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).*)"],
}
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)
}
})
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 (
...
);
}
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",
},
...
})
Create a Login page and implement signin()
We will do this using server actions -
- create signin request action
- create signin form component (client side). Bind reqeuestSignIn using
useFormState
- 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/');
}
`
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>
)
}
`
@/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>
)
}
`
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
},
}
})
`
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 typeUser | 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;
}
}
Additional resources -
- (Youtube) Next.js App Router Authenication - https://www.youtube.com/watch?v=DJvM2lSPn6w
- Practical overview of Nextjs forms and server actions - https://www.robinwieruch.de/next-forms/
Top comments (0)