NextAuth.js v5 Tutorial: Complete Authentication for Next.js
Rabu, 15 Jan 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 many significant changes that make setup simpler and performance better. Let’s dive in from start to production-ready!
Why NextAuth.js?
Before getting into the tutorial, why should you use NextAuth.js?
- Zero-config for OAuth - Setting up Google, GitHub, and other providers only takes a few lines
- Built-in session management - No need to build from scratch
- Database adapter - 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"
Key changes:
- Unified configuration - One
auth.tsfile for all config - Native App Router support - Route handlers instead of API routes
- Edge Runtime ready - Deploy on edge without issues
- Middleware authentication - Protect routes directly in middleware
- Improved TypeScript - Better type inference
Setup and Installation
Let’s start from scratch. Make sure you already 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://..."
auth.ts Configuration
Create an auth.ts file at the project root or in the 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 a 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 login with email and password:
// 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 a 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)
Add OAuth providers:
// 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,
}),
GitHub({
clientId: process.env.AUTH_GITHUB_ID,
clientSecret: process.env.AUTH_GITHUB_SECRET,
}),
],
})
OAuth buttons component:
// components/oauth-buttons.tsx
"use client"
import { signIn } from "next-auth/react"
export function OAuthButtons() {
return (
<div className="space-y-2">
<button
onClick={() => signIn("google", { callbackUrl: "/dashboard" })}
className="w-full flex items-center justify-center gap-2 p-2 border 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 p-2 border rounded hover:bg-gray-50"
>
<GitHubIcon />
Continue with GitHub
</button>
</div>
)
}
Database Adapter (Prisma)
To persist sessions and users to the database:
npm install @auth/prisma-adapter
Update Prisma schema:
// prisma/schema.prisma
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[]
}
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])
}
Configure adapter:
// auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
session: { strategy: "jwt" },
providers: [
// ... providers
],
})
Middleware for Route Protection
Create middleware.ts at the project root:
// middleware.ts
import { auth } from "@/auth"
export default auth((req) => {
const isLoggedIn = !!req.auth
const { pathname } = req.nextUrl
// Protected routes
const protectedRoutes = ["/dashboard", "/settings", "/profile"]
const isProtected = protectedRoutes.some((route) =>
pathname.startsWith(route)
)
if (isProtected && !isLoggedIn) {
const loginUrl = new URL("/login", req.url)
loginUrl.searchParams.set("callbackUrl", pathname)
return Response.redirect(loginUrl)
}
// Redirect logged in users away from auth pages
const authRoutes = ["/login", "/register"]
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route))
if (isAuthRoute && isLoggedIn) {
return Response.redirect(new URL("/dashboard", req.url))
}
})
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
}
Session and User Data
Server Component
// 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>Dashboard</h1>
<p>Welcome, {session.user?.name}!</p>
<p>Email: {session.user?.email}</p>
</div>
)
}
Client Component
// components/user-menu.tsx
"use client"
import { useSession, signOut } from "next-auth/react"
export function UserMenu() {
const { data: session, status } = useSession()
if (status === "loading") {
return <div>Loading...</div>
}
if (!session) {
return <a href="/login">Login</a>
}
return (
<div className="flex items-center gap-4">
<span>{session.user?.name}</span>
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="text-red-600 hover:underline"
>
Logout
</button>
</div>
)
}
SessionProvider Setup
// app/providers.tsx
"use client"
import { SessionProvider } from "next-auth/react"
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>
}
// 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>
)
}
Role-Based Access Control (RBAC)
Extend Session Type
// types/next-auth.d.ts
import { DefaultSession } from "next-auth"
declare module "next-auth" {
interface Session {
user: {
id: string
role: string
} & DefaultSession["user"]
}
interface User {
role: string
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string
role: string
}
}
Configure Callbacks
// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
// ... other config
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id
token.role = user.role
}
return token
},
async session({ session, token }) {
if (token && session.user) {
session.user.id = token.id
session.user.role = token.role
}
return session
},
},
})
RequireRole Component
// components/require-role.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
interface RequireRoleProps {
allowedRoles: string[]
children: React.ReactNode
}
export async function RequireRole({
allowedRoles,
children,
}: RequireRoleProps) {
const session = await auth()
if (!session) {
redirect("/login")
}
if (!allowedRoles.includes(session.user.role)) {
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>
)
}
Custom Pages (Sign-in, Error)
Create a custom sign-in page:
// app/login/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"
import { LoginForm } from "@/components/login-form"
import { OAuthButtons } from "@/components/oauth-buttons"
export default async function LoginPage() {
const session = await auth()
if (session) {
redirect("/dashboard")
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow">
<div className="text-center">
<h2 className="text-3xl font-bold">Login</h2>
<p className="mt-2 text-gray-600">
Sign in to your account
</p>
</div>
<OAuthButtons />
<div className="relative">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-gray-300" />
</div>
<div className="relative flex justify-center text-sm">
<span className="px-2 bg-white text-gray-500">
Or with email
</span>
</div>
</div>
<LoginForm />
<p className="text-center text-sm text-gray-600">
Don't have an account?{" "}
<a href="/register" className="text-blue-600 hover:underline">
Register
</a>
</p>
</div>
</div>
)
}
Custom error page:
// app/auth/error/page.tsx
"use client"
import { useSearchParams } from "next/navigation"
import Link from "next/link"
const errorMessages: Record<string, string> = {
Configuration: "There's a problem with the server configuration.",
AccessDenied: "You don't have access to this page.",
Verification: "The verification link has expired or is invalid.",
Default: "An error occurred during login.",
}
export default function AuthErrorPage() {
const searchParams = useSearchParams()
const error = searchParams.get("error")
const errorMessage = error
? errorMessages[error] || errorMessages.Default
: errorMessages.Default
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full text-center space-y-4 p-8 bg-white rounded-xl shadow">
<div className="text-red-500 text-5xl">⚠️</div>
<h1 className="text-2xl font-bold">Authentication Error</h1>
<p className="text-gray-600">{errorMessage}</p>
<Link
href="/login"
className="inline-block bg-blue-600 text-white px-6 py-2 rounded hover:bg-blue-700"
>
Back to Login
</Link>
</div>
</div>
)
}
Security Best Practices
Some security tips you must follow:
1. Environment Variables
# DO NOT commit to repository!
AUTH_SECRET="use-a-long-random-string"
AUTH_TRUST_HOST=true # Only for production
2. CSRF Protection
NextAuth.js v5 already includes CSRF protection by default. Make sure you don’t disable it:
// ❌ Don't do this
export const { handlers } = NextAuth({
trustHost: true,
// ...
})
// ✅ Leave default or set explicitly
export const { handlers } = NextAuth({
// trustHost will auto-detect
})
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. Secure Headers
// next.config.js
const securityHeaders = [
{
key: "X-DNS-Prefetch-Control",
value: "on",
},
{
key: "Strict-Transport-Security",
value: "max-age=63072000; includeSubDomains; preload",
},
{
key: "X-Content-Type-Options",
value: "nosniff",
},
{
key: "Referrer-Policy",
value: "origin-when-cross-origin",
},
]
module.exports = {
async headers() {
return [
{
source: "/:path*",
headers: securityHeaders,
},
]
},
}
5. 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 a single
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 highly recommended. If you’re still on v4, consider migrating as v5 is now stable and offers better performance.
Happy coding!