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.
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-secret// 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 } = handlersNextAuth'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.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).*)'],
}// 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>
)
}// 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>
)
}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.
auth() in Server Components and useSession() in Client Componentsdeclare module 'next-auth' to get full TypeScript supportstrategy: 'jwt' for edge-compatible sessions (no database required for session storage)callbackUrl to login redirects so users return to their intended page