Next.js Authentication — NextAuth.js v5 Complete Guide for 2026
Advertisement
Introduction
Why This Matters
Authentication is one of the most error-prone parts of any web application. Rolling your own auth means dealing with password hashing, session management, CSRF protection, and token rotation — all of which have well-known failure modes. NextAuth.js v5 (Auth.js) solves these problems with a framework-native API that integrates seamlessly with the App Router.
NextAuth.js v5 is a ground-up rewrite of v4. It introduces a unified auth() function that works in Server Components, Route Handlers, Server Actions, and middleware — replacing the fragmented getServerSession / useSession split.
Installing and Configuring NextAuth.js v5
npm install next-auth@betaCreate the main auth config file:
// auth.ts (root of project)
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
import Google from 'next-auth/providers/google'
import Credentials from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from '@/lib/prisma'
import bcrypt from 'bcryptjs'
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
GitHub({
clientId: process.env.GITHUB_ID!,
clientSecret: process.env.GITHUB_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_ID!,
clientSecret: process.env.GOOGLE_SECRET!,
}),
Credentials({
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
})
if (!user || !user.password) return null
const valid = await bcrypt.compare(
credentials.password as string,
user.password
)
return valid ? { id: user.id, email: user.email, name: user.name } : null
},
}),
],
pages: {
signIn: '/login',
error: '/auth/error',
},
callbacks: {
async session({ session, token }) {
if (token?.sub) session.user.id = token.sub
return session
},
},
})Mount the catch-all route handler:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlersRequired environment variables:
AUTH_SECRET=your_32_char_random_secret
GITHUB_ID=your_github_app_client_id
GITHUB_SECRET=your_github_app_client_secret
GOOGLE_ID=your_google_client_id
GOOGLE_SECRET=your_google_client_secret
DATABASE_URL=postgresql://user:pass@localhost:5432/mydbProtecting Routes with Middleware
The most scalable way to protect routes is middleware, which runs on the Vercel Edge Network before any page renders:
// middleware.ts
import { auth } from '@/auth'
export default auth((req) => {
const isLoggedIn = !!req.auth
const isAuthRoute = req.nextUrl.pathname.startsWith('/auth')
const isProtected = req.nextUrl.pathname.startsWith('/dashboard') ||
req.nextUrl.pathname.startsWith('/admin')
if (isProtected && !isLoggedIn) {
const loginUrl = new URL('/login', req.nextUrl)
loginUrl.searchParams.set('callbackUrl', req.nextUrl.pathname)
return Response.redirect(loginUrl)
}
if (isAuthRoute && isLoggedIn) {
return Response.redirect(new URL('/dashboard', req.nextUrl))
}
})
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*', '/auth/:path*'],
}Using the Session in Server Components
The auth() function is async and works natively in Server Components:
// app/dashboard/page.tsx
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const session = await auth()
if (!session?.user) {
redirect('/login')
}
return (
<main>
<h1>Welcome back, {session.user.name}</h1>
<p>Email: {session.user.email}</p>
</main>
)
}Building the Login Page
// app/login/page.tsx
'use client'
import { signIn } from 'next-auth/react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState } from 'react'
export default function LoginPage() {
const router = useRouter()
const searchParams = useSearchParams()
const callbackUrl = searchParams.get('callbackUrl') ?? '/dashboard'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
const result = await signIn('credentials', {
email,
password,
redirect: false,
})
if (result?.error) {
setError('Invalid email or password')
return
}
router.push(callbackUrl)
}
return (
<div className="max-w-md mx-auto mt-16 p-6 border rounded-lg">
<h1 className="text-2xl font-bold mb-6">Sign in</h1>
<form onSubmit={handleCredentials} className="space-y-4 mb-6">
{error && <p className="text-red-600 text-sm">{error}</p>}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
required
className="w-full border p-2 rounded"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
className="w-full border p-2 rounded"
/>
<button type="submit" className="w-full bg-blue-600 text-white py-2 rounded">
Sign in with Email
</button>
</form>
<div className="space-y-2">
<button
onClick={() => signIn('github', { callbackUrl })}
className="w-full bg-gray-900 text-white py-2 rounded"
>
Sign in with GitHub
</button>
<button
onClick={() => signIn('google', { callbackUrl })}
className="w-full bg-red-500 text-white py-2 rounded"
>
Sign in with Google
</button>
</div>
</div>
)
}User Registration API
// app/api/register/route.ts
import { NextRequest, NextResponse } from 'next/server'
import bcrypt from 'bcryptjs'
import { prisma } from '@/lib/prisma'
export async function POST(request: NextRequest) {
const { email, password, name } = await request.json()
if (!email || !password || password.length < 8) {
return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
}
const existing = await prisma.user.findUnique({ where: { email } })
if (existing) {
return NextResponse.json({ error: 'User already exists' }, { status: 400 })
}
const hashed = await bcrypt.hash(password, 12)
const user = await prisma.user.create({
data: { email, name, password: hashed },
select: { id: true, email: true, name: true },
})
return NextResponse.json(user, { status: 201 })
}Magic Link (Passwordless) Authentication
// auth.ts — add Email provider
import Email from 'next-auth/providers/nodemailer'
providers: [
Email({
server: process.env.EMAIL_SERVER, // smtp://user:pass@smtp.example.com:587
from: process.env.EMAIL_FROM, // noreply@example.com
}),
]NextAuth.js sends a one-time link to the user's email. No passwords to store or rotate.
Common Mistakes
- Forgetting to set
AUTH_SECRETin production — NextAuth.js will throw a cryptic error at startup - Using
getServerSessionfrom NextAuth v4 in an App Router project — useauth()from v5 instead - Not adding the Prisma adapter when using OAuth providers — without it, users cannot be persisted across sessions
- Storing sensitive data like roles directly in the JWT without validating on the server — always re-fetch from the database for privileged actions
- Redirecting to an open URL from
callbackUrlwithout validating the origin — always validate the callback URL is within your own domain
Best Practices
- Store user roles in the database, not the JWT; read them from the session callback on each request
- Use
bcryptwith a cost factor of 12 or higher for password hashing - Enable HTTPS-only cookies in production by setting
useSecureCookies: true(default whenAUTH_URLis HTTPS) - Implement rate limiting on
/api/registerand/api/auth/callback/credentialsto prevent brute force attacks - Use the Prisma adapter with database sessions for admin dashboards where revocation matters
Key Takeaways
- NextAuth.js v5 ships a unified
auth()function that works in Server Components, middleware, Server Actions, and Route Handlers - OAuth providers (GitHub, Google) require creating an OAuth app in each platform and setting client ID/secret as environment variables
- Middleware-based route protection runs at the edge and is more efficient than per-page redirects
- The Prisma adapter persists OAuth accounts and sessions to your database automatically
bcryptwith cost factor 12 is the minimum recommended for password hashing in 2026- Magic links (passwordless) via the Email provider eliminate password storage entirely
- Always validate
callbackUrlto prevent open redirect vulnerabilities - Credentials provider requires a custom
authorizefunction; it does not auto-create users
Advertisement