Authentication in Next.js with NextAuth.js v5: The Complete Setup
Learn how to implement authentication in Next.js using NextAuth.js v5 — covering credentials, OAuth providers, JWT sessions, protected routes, and role-based access control.
Authentication is one of those things that looks simple and turns out to be a rabbit hole. Sessions, tokens, OAuth flows, CSRF protection, secure cookies — there's a lot to get right, and getting it wrong has security consequences.
NextAuth.js (now Auth.js) handles all of this for you. Version 5 was a significant rewrite that works seamlessly with Next.js App Router. Here's how to set it up correctly.
Installation
npm install next-auth@beta
# Generate a secret key
openssl rand -base64 32Add to .env.local:
AUTH_SECRET=your-generated-secret
AUTH_GOOGLE_ID=your-google-client-id
AUTH_GOOGLE_SECRET=your-google-client-secretCore Configuration
// auth.ts — in the project root
import NextAuth from 'next-auth'
import Google from 'next-auth/providers/google'
import GitHub from 'next-auth/providers/github'
import Credentials from 'next-auth/providers/credentials'
import { db } from '@/lib/db'
import bcrypt from 'bcryptjs'
import { z } from 'zod'
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Google,
GitHub,
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
const parsed = z.object({
email: z.string().email(),
password: z.string().min(1),
}).safeParse(credentials)
if (!parsed.success) return null
const user = await db.user.findUnique({
where: { email: parsed.data.email },
})
if (!user?.password) return null
const valid = await bcrypt.compare(parsed.data.password, user.password)
if (!valid) return null
return { id: user.id, email: user.email, name: user.name, role: user.role }
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = (user as any).role
}
return token
},
async session({ session, token }) {
session.user.id = token.id as string
session.user.role = token.role as string
return session
},
},
pages: {
signIn: '/login',
error: '/auth/error',
},
session: { strategy: 'jwt' },
})// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlersExtending the Session Type
NextAuth's default session type doesn't include custom fields. Extend it:
// types/next-auth.d.ts
import 'next-auth'
declare module 'next-auth' {
interface Session {
user: {
id: string
email: string
name: string | null
image: string | null
role: string
}
}
}Middleware for Route Protection
// middleware.ts
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export default auth(req => {
const { pathname } = req.nextUrl
const isAuthenticated = !!req.auth
// Public routes
const publicPaths = ['/login', '/register', '/about', '/']
const isPublic = publicPaths.some(path =>
pathname === path || pathname.startsWith('/post/')
)
if (!isAuthenticated && !isPublic) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// Admin routes
if (pathname.startsWith('/admin') && req.auth?.user?.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', req.url))
}
return NextResponse.next()
})
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}Login Form with Server Action
// app/login/page.tsx
import { signIn } from '@/auth'
import { AuthError } from 'next-auth'
import { redirect } from 'next/navigation'
export default function LoginPage({
searchParams,
}: {
searchParams: { callbackUrl?: string; error?: string }
}) {
async function handleLogin(formData: FormData) {
'use server'
try {
await signIn('credentials', {
email: formData.get('email'),
password: formData.get('password'),
redirectTo: searchParams.callbackUrl || '/dashboard',
})
} catch (error) {
if (error instanceof AuthError) {
redirect(`/login?error=${error.type}`)
}
throw error
}
}
return (
<div className="min-h-screen flex items-center justify-center">
<div className="w-full max-w-sm space-y-6">
<h1 className="text-2xl font-bold text-center">Sign In</h1>
{searchParams.error && (
<div className="bg-red-50 text-red-600 p-3 rounded text-sm">
{searchParams.error === 'CredentialsSignin'
? 'Invalid email or password'
: 'Authentication failed'}
</div>
)}
<form action={handleLogin} className="space-y-4">
<input
name="email"
type="email"
placeholder="Email"
required
className="w-full border rounded-lg px-4 py-2"
/>
<input
name="password"
type="password"
placeholder="Password"
required
className="w-full border rounded-lg px-4 py-2"
/>
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded-lg font-medium"
>
Sign in
</button>
</form>
<div className="space-y-2">
<form action={async () => { 'use server'; await signIn('google', { redirectTo: '/dashboard' }) }}>
<button type="submit" className="w-full border py-2 rounded-lg">
Continue with Google
</button>
</form>
<form action={async () => { 'use server'; await signIn('github', { redirectTo: '/dashboard' }) }}>
<button type="submit" className="w-full border py-2 rounded-lg">
Continue with GitHub
</button>
</form>
</div>
</div>
</div>
)
}Getting the Session in Components
// Server Component
import { auth } from '@/auth'
export default async function Dashboard() {
const session = await auth()
if (!session) return null
return <div>Welcome, {session.user.name}</div>
}// Client Component
'use client'
import { useSession } from 'next-auth/react'
export default function UserMenu() {
const { data: session, status } = useSession()
if (status === 'loading') return <div>Loading...</div>
if (!session) return <a href="/login">Sign in</a>
return (
<div>
<img src={session.user.image ?? ''} alt={session.user.name ?? ''} />
<span>{session.user.name}</span>
</div>
)
}Don't forget to wrap your app in SessionProvider for client-side session access:
// app/layout.tsx
import { SessionProvider } from 'next-auth/react'
import { auth } from '@/auth'
export default async function RootLayout({ children }) {
const session = await auth()
return (
<html>
<body>
<SessionProvider session={session}>
{children}
</SessionProvider>
</body>
</html>
)
}Common Mistakes
1. Not setting AUTH_SECRET. Without it, sessions are insecure and production will break.
2. Checking auth in every page instead of middleware. Use middleware for consistent protection. Per-page checks are error-prone.
3. Storing sensitive data in the JWT token. JWT tokens are base64 encoded, not encrypted by default. Don't store passwords, payment info, or anything sensitive.
4. Missing callbackUrl handling. Always preserve the intended destination through the login redirect so users land where they were going.
Key Takeaways
- NextAuth.js v5 works natively with App Router — use
auth()in Server Components anduseSession()in Client Components - Middleware is the right place to protect routes — not individual pages
- Extend session types with
declare module 'next-auth'to get full TypeScript support - Use
strategy: 'jwt'for edge-compatible sessions (no database required for session storage) - Always add
callbackUrlto login redirects so users return to their intended page

Written by
Sabir KhaloufiFull-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.