Tutorial NextAuth.js v5: Authentication Lengkap untuk Next.js - Nayaka Yoga Pradipta
ID | EN

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.ts untuk 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.ts untuk 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! 🚀