Tutorial NextAuth.js v5: Authentication Lengkap untuk Next.js
Kamis, 25 Des 2025
Kalau kamu lagi build aplikasi Next.js dan butuh authentication, NextAuth.js (sekarang dikenal sebagai Auth.js) adalah pilihan yang solid. Di versi 5 ini, banyak perubahan signifikan yang bikin setup lebih simpel dan performa lebih oke. Yuk, kita bahas dari awal sampai production-ready!
Kenapa NextAuth.js?
Sebelum masuk ke tutorial, kenapa sih harus NextAuth.js?
- Zero-config untuk OAuth - Setup Google, GitHub, dan provider lain cuma butuh beberapa baris
- Session management built-in - Gak perlu bikin dari scratch
- Database adapter - Integrasi mulus dengan Prisma, Drizzle, atau database lain
- TypeScript-first - Full type safety
- Edge-compatible - Bisa jalan di Vercel Edge Functions
What’s New di NextAuth.js v5 (Auth.js)
Versi 5 bawa perubahan besar:
// v4 (lama)
import NextAuth from "next-auth"
// v5 (baru)
import NextAuth from "next-auth"
// Atau untuk universal support:
import { Auth } from "@auth/core"
Perubahan utama:
- Unified configuration - Satu file
auth.tsuntuk semua config - Native App Router support - Route handlers bukan API routes
- Edge Runtime ready - Bisa deploy di edge tanpa masalah
- Middleware authentication - Protect routes langsung di middleware
- Improved TypeScript - Better type inference
Setup dan Instalasi
Mari kita mulai dari awal. Pastikan kamu sudah punya project Next.js 14+ dengan App Router.
# Install NextAuth.js v5
npm install next-auth@beta
# Atau dengan pnpm
pnpm add next-auth@beta
Generate secret untuk production:
npx auth secret
Tambahkan ke .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://..."
Konfigurasi auth.ts
Buat file auth.ts di root project atau di folder src/:
// 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 ke login
}
return true
},
},
}
export const { handlers, auth, signIn, signOut } = NextAuth(authConfig)
Buat route handler di 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)
Untuk login dengan email dan password tradisional:
// 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
})
Buat form login 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("Email atau password salah")
return
}
router.push("/dashboard")
router.refresh()
} catch {
setError("Terjadi kesalahan")
} 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)
Setup OAuth provider sangat straightforward di 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,
}),
],
})
Buat 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)
Untuk persist user data, kita pakai 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 dengan 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
},
},
})
Buat 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
Di v5, session management lebih fleksibel:
// auth.ts
export const { handlers, auth, signIn, signOut } = NextAuth({
session: {
strategy: "jwt", // atau "database"
maxAge: 30 * 24 * 60 * 60, // 30 hari
updateAge: 24 * 60 * 60, // Update setiap 24 jam
},
jwt: {
maxAge: 30 * 24 * 60 * 60,
},
// ...
})
Extend session dan 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)
Gunakan middleware untuk protect routes secara efisien:
// 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)
Untuk akses session di 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 di layout:
// app/layout.tsx
import { Providers } from "./providers"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="id">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}
Gunakan di 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>
)
}
Untuk server components, gunakan auth() langsung:
// 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
Implementasi RBAC yang proper:
// 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
}
Buat 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}</>
}
Penggunaan:
// 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)
Buat 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">
Masuk ke akun kamu
</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">
Atau dengan email
</span>
</div>
</div>
<LoginForm />
<p className="text-center text-sm text-gray-600">
Belum punya akun?{" "}
<a href="/register" className="text-blue-600 hover:underline">
Daftar
</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: "Ada masalah dengan konfigurasi server.",
AccessDenied: "Kamu tidak punya akses ke halaman ini.",
Verification: "Link verifikasi sudah expired atau tidak valid.",
Default: "Terjadi kesalahan saat 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"
>
Kembali ke Login
</Link>
</div>
</div>
)
}
Best Practices Security
Beberapa tips keamanan yang wajib diperhatikan:
1. Environment Variables
# JANGAN commit ke repository!
AUTH_SECRET="gunakan-random-string-yang-panjang"
AUTH_TRUST_HOST=true # Hanya untuk production
2. CSRF Protection
NextAuth.js v5 sudah include CSRF protection by default. Pastikan kamu gak disable:
// ❌ Jangan lakukan ini
export const { handlers } = NextAuth({
trustHost: true,
// ...
})
// ✅ Biarkan default atau set explicit
export const { handlers } = NextAuth({
// trustHost akan auto-detect
})
3. Rate Limiting
Tambahkan rate limiting untuk 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
Selalu hash password dengan bcrypt atau argon2:
import bcrypt from "bcryptjs"
// Register
const hashedPassword = await bcrypt.hash(password, 12)
// Login
const isValid = await bcrypt.compare(password, user.password)
Kesimpulan
NextAuth.js v5 adalah upgrade yang signifikan dari versi sebelumnya. Dengan unified configuration, better TypeScript support, dan edge compatibility, implementasi authentication jadi lebih straightforward.
Key takeaways:
- Gunakan satu file
auth.tsuntuk semua konfigurasi - Manfaatkan middleware untuk route protection
- Pilih session strategy yang sesuai (JWT untuk stateless, database untuk more control)
- Selalu implement proper security practices
- Extend types untuk TypeScript yang lebih baik
Untuk project baru, NextAuth.js v5 adalah pilihan yang sangat recommended. Kalau kamu masih pakai v4, pertimbangkan untuk migrate karena v5 sudah stable dan performanya lebih baik.
Happy coding! 🚀