NextAuth.js v5 Tutorial: Complete Authentication for Next.js
Thursday, Dec 25, 2025
If you’re building a Next.js application and need authentication, NextAuth.js (now known as Auth.js) is a solid choice. In version 5, there are significant changes that make setup simpler and performance better. Let’s dive in from start to production-ready!
Why NextAuth.js?
Before we get into the tutorial, why should you use NextAuth.js?
- Zero-config for OAuth - Setting up Google, GitHub, and other providers only requires a few lines
- Built-in session management - No need to build from scratch
- Database adapters - Seamless integration with Prisma, Drizzle, or other databases
- TypeScript-first - Full type safety
- Edge-compatible - Can run on Vercel Edge Functions
What’s New in NextAuth.js v5 (Auth.js)
Version 5 brings major changes:
// v4 (old)
import NextAuth from "next-auth"
// v5 (new)
import NextAuth from "next-auth"
// Or for universal support:
import { Auth } from "@auth/core"
Main changes:
- Unified configuration - One
auth.tsfile for all config - Native App Router support - Route handlers instead of API routes
- Edge Runtime ready - Can deploy on edge without issues
- Middleware authentication - Protect routes directly in middleware
- Improved TypeScript - Better type inference
Setup and Installation
Let’s start from the beginning. Make sure you have a Next.js 14+ project with App Router.
# Install NextAuth.js v5
npm install next-auth@beta
# Or with pnpm
pnpm add next-auth@beta
Generate a secret for production:
npx auth secret
Add to .env.local:
AUTH_SECRET="your-generated-secret-here"
# OAuth Providers (optional)
AUTH_GOOGLE_ID="your-google-client-id"
AUTH_GOOGLE_SECRET="your-google-client-secret"
AUTH_GITHUB_ID="your-github-client-id"
AUTH_GITHUB_SECRET="your-github-client-secret"
# Database (optional)
DATABASE_URL="postgresql://..."
Configuring auth.ts
Create file auth.ts in project root or src/ folder:
// auth.ts
import NextAuth from "next-auth"
import { NextAuthConfig } from "next-auth"
export const authConfig: NextAuthConfig = {
providers: [],
pages: {
signIn: "/login",
error: "/auth/error",
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isOnDashboard = nextUrl.pathname.startsWith("/dashboard")
if (isOnDashboard) {
if (isLoggedIn) return true
return false // Redirect to login
}
return true
},
},
}
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig)
Create route handler at app/api/auth/[...nextauth]/route.ts:
// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth"
export const { GET, POST } = handlers
Credentials Provider (Email/Password)
For traditional email and password login:
// auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
import bcrypt from "bcryptjs"
import { getUserByEmail } from "@/lib/user"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Credentials({
name: "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 getUserByEmail(credentials.email as string)
if (!user || !user.password) {
return null
}
const passwordMatch = await bcrypt.compare(
credentials.password as string,
user.password
)
if (!passwordMatch) {
return null
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
},
}),
],
// ... rest of config
})
Create login form component:
// components/login-form.tsx
"use client"
import { signIn } from "next-auth/react"
import { useState } from "react"
import { useRouter } from "next/navigation"
export function LoginForm() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError("")
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
})
if (result?.error) {
setError("Invalid email or password")
return
}
router.push("/dashboard")
router.refresh()
} catch {
setError("An error occurred")
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-100 text-red-600 p-3 rounded">
{error}
</div>
)}
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full p-2 border rounded"
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full p-2 border rounded"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white p-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "Loading..." : "Login"}
</button>
</form>
)
}
OAuth Providers (Google, GitHub)
Setting up OAuth providers is very straightforward in v5:
// auth.ts
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
authorization: {
params: {
prompt: "consent",
access_type: "offline",
response_type: "code",
},
},
}),
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
],
})
Create OAuth buttons:
// components/oauth-buttons.tsx
"use client"
import { signIn } from "next-auth/react"
export function OAuthButtons() {
return (
<div className="space-y-3">
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className="w-full flex items-center justify-center gap-2 bg-white border p-2 rounded hover:bg-gray-50"
>
<GoogleIcon />
Continue with Google
</button>
<button
onClick={() => signIn("github", { callbackUrl: "/dashboard" })}
className="w-full flex items-center justify-center gap-2 bg-gray-900 text-white p-2 rounded hover:bg-gray-800"
>
<GitHubIcon />
Continue with GitHub
</button>
</div>
)
}
Database Adapters (Prisma)
To persist user data, we use the Prisma adapter:
npm install @auth/prisma-adapter
npm install prisma @prisma/client
Setup Prisma schema:
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String?
role String @default("user")
accounts Account[]
sessions Session[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
Integrate with NextAuth:
// auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import Google from "next-auth/providers/google"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
Google({
clientId: process.env.AUTH_GOOGLE_ID,
clientSecret: process.env.AUTH_GOOGLE_SECRET,
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub!
session.user.role = token.role as string
}
return session
},
},
})
Create Prisma client singleton:
// lib/prisma.ts
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma
}
Session Management
In v5, session management is more flexible:
// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: "jwt", // or "database"
maxAge: 30 * 24 * 60 * 60, // 30 days
updateAge: 24 * 60 * 60, // Update every 24 hours
},
jwt: {
maxAge: 30 * 24 * 60 * 60,
},
// ...
})
Extend session and JWT types:
// types/next-auth.d.ts
import { DefaultSession, DefaultUser } from "next-auth"
import { JWT, DefaultJWT } from "next-auth/jwt"
declare module "next-auth" {
interface Session {
user: {
id: string
role: string
} & DefaultSession["user"]
}
interface User extends DefaultUser {
role: string
}
}
declare module "next-auth/jwt" {
interface JWT extends DefaultJWT {
role: string
}
}
Protecting Routes (Middleware)
Use middleware to protect routes efficiently:
// middleware.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"
export default auth((req) => {
const { nextUrl } = req
const isLoggedIn = !!req.auth
const isPublicRoute = ["/", "/login", "/register", "/api/auth"].some(
(route) => nextUrl.pathname.startsWith(route)
)
const isProtectedRoute = nextUrl.pathname.startsWith("/dashboard")
const isAdminRoute = nextUrl.pathname.startsWith("/admin")
// Redirect logged-in users away from auth pages
if (isLoggedIn && (nextUrl.pathname === "/login" || nextUrl.pathname === "/register")) {
return NextResponse.redirect(new URL("/dashboard", nextUrl))
}
// Protect dashboard routes
if (isProtectedRoute && !isLoggedIn) {
return NextResponse.redirect(new URL("/login", nextUrl))
}
// Protect admin routes
if (isAdminRoute) {
if (!isLoggedIn) {
return NextResponse.redirect(new URL("/login", nextUrl))
}
if (req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/dashboard", nextUrl))
}
}
return NextResponse.next()
})
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
Client-side Session (useSession)
For accessing session in client components:
// app/providers.tsx
"use client"
import { SessionProvider } from "next-auth/react"
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
Wrap in layout:
// app/layout.tsx
import { Providers } from "./providers"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Use in component:
// components/user-nav.tsx
"use client"
import { useSession, signOut } from "next-auth/react"
import Link from "next/link"
export function UserNav() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div className="animate-pulse w-8 h-8 bg-gray-200 rounded-full" />
}
if (!session) {
return (
<Link href="/login" className="text-sm hover:underline">
Login
</Link>
)
}
return (
<div className="flex items-center gap-4">
<span className="text-sm">{session.user.name}</span>
<img
src={session.user.image || "/default-avatar.png"}
alt="Avatar"
className="w-8 h-8 rounded-full"
/>
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="text-sm text-red-600 hover:underline"
>
Logout
</button>
</div>
)
}
For server components, use auth() directly:
// app/dashboard/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
export default async function DashboardPage() {
const session = await auth()
if (!session) {
redirect("/login")
}
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<p>Role: {session.user.role}</p>
</div>
)
}
Role-based Access Control
Implementing proper RBAC:
// lib/permissions.ts
export type Role = "user" | "admin" | "moderator"
export const permissions = {
user: ["read:own_profile", "update:own_profile"],
moderator: [
"read:own_profile",
"update:own_profile",
"read:all_users",
"moderate:content",
],
admin: [
"read:own_profile",
"update:own_profile",
"read:all_users",
"moderate:content",
"manage:users",
"manage:settings",
],
} as const
export function hasPermission(role: Role, permission: string): boolean {
return permissions[role]?.includes(permission as any) ?? false
}
Create helper component:
// components/require-role.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
interface RequireRoleProps {
children: React.ReactNode
allowedRoles: string[]
fallback?: React.ReactNode
}
export async function RequireRole({
children,
allowedRoles,
fallback,
}: RequireRoleProps) {
const session = await auth()
if (!session) {
redirect("/login")
}
if (!allowedRoles.includes(session.user.role)) {
if (fallback) return <>{fallback}</>
redirect("/unauthorized")
}
return <>{children}</>
}
Usage:
// app/admin/page.tsx
import { RequireRole } from "@/components/require-role"
export default function AdminPage() {
return (
<RequireRole allowedRoles={["admin"]}>
<div>
<h1>Admin Dashboard</h1>
{/* Admin content */}
</div>
</RequireRole>
)
}
Security Best Practices
Some security tips you must follow:
1. Environment Variables
# DON'T commit to repository!
AUTH_SECRET="use-a-long-random-string"
AUTH_TRUST_HOST=true # Only for production
2. CSRF Protection
NextAuth.js v5 includes CSRF protection by default. Make sure you don’t disable it.
3. Rate Limiting
Add rate limiting for login:
// lib/rate-limit.ts
import { LRUCache } from "lru-cache"
type RateLimitOptions = {
interval: number
uniqueTokenPerInterval: number
}
export function rateLimit(options: RateLimitOptions) {
const tokenCache = new LRUCache({
max: options.uniqueTokenPerInterval,
ttl: options.interval,
})
return {
check: (token: string, limit: number) =>
new Promise<void>((resolve, reject) => {
const tokenCount = (tokenCache.get(token) as number[]) || [0]
if (tokenCount[0] === 0) {
tokenCache.set(token, [1])
}
tokenCount[0] += 1
const currentUsage = tokenCount[0]
const isRateLimited = currentUsage > limit
if (isRateLimited) {
reject(new Error("Rate limit exceeded"))
} else {
resolve()
}
}),
}
}
4. Password Hashing
Always hash passwords with bcrypt or argon2:
import bcrypt from "bcryptjs"
// Register
const hashedPassword = await bcrypt.hash(password, 12)
// Login
const isValid = await bcrypt.compare(password, user.password)
Conclusion
NextAuth.js v5 is a significant upgrade from the previous version. With unified configuration, better TypeScript support, and edge compatibility, implementing authentication becomes more straightforward.
Key takeaways:
- Use one
auth.tsfile for all configuration - Leverage middleware for route protection
- Choose the appropriate session strategy (JWT for stateless, database for more control)
- Always implement proper security practices
- Extend types for better TypeScript support
For new projects, NextAuth.js v5 is a highly recommended choice. If you’re still on v4, consider migrating as v5 is stable and performs better.
Happy coding! 🚀