Next.js 16 + Auth.js di 2026: Panduan Lengkap Membangun Login Aman untuk Aplikasi Web
Tutorial lengkap Bahasa Indonesia untuk membangun authentication modern di Next.js 16 dengan Auth.js. Mencakup arsitektur, implementasi runnable, best practices, kesalahan umum, dan tips production-grade.
Next.js 16 + Auth.js di 2026: Bangun Sistem Login Aman, Cepat, dan Siap Production
1) Introduction — What and Why
Kalau kamu bikin aplikasi web di 2026, hampir pasti pertanyaan pertama dari user adalah: “Bisa login pakai Google?”, lalu pertanyaan kedua dari tim: “Aman nggak?”.
Di sisi lain, developer sekarang juga dituntut cepat. Nggak cukup cuma “bisa jalan di localhost”, tapi harus:
- aman untuk production,
- performanya bagus,
- scalable,
- gampang dipelihara saat tim bertambah.
Di sinilah kombinasi Next.js 16 (App Router) + Auth.js jadi sangat relevan. Next.js memberi fondasi full-stack web app modern (SSR, Server Components, Route Handlers, caching, middleware), sementara Auth.js menyederhanakan alur autentikasi seperti OAuth, credentials, magic link, sampai passkey.
Dalam tutorial ini, kita akan membangun aplikasi mini “TaskBoard” dengan fitur:
- Register + login email/password
- Login Google OAuth
- Session management berbasis JWT
- Route protection (halaman dashboard hanya untuk user login)
- Error handling yang proper
- Struktur code yang siap dikembangkan
Kenapa ini penting di dunia nyata? Bayangkan kamu membangun:
- dashboard internal perusahaan,
- SaaS untuk UMKM,
- platform pembelajaran,
- portal admin e-commerce.
Semua itu butuh auth yang benar dari awal. Salah desain di auth itu mahal: bug keamanan, user lockout, sampai kebocoran data.
Analogi sederhana: auth itu seperti pintu kantor + resepsionis + kartu akses. Kalau pintunya bagus tapi resepsionis asal kasih kartu, tetap bahaya.
2) Prerequisites — Yang Perlu Disiapkan
Sebelum mulai, pastikan kamu punya:
- Node.js 20+
- npm/pnpm/yarn (contoh di tutorial ini pakai npm)
- Basic React dan JavaScript/TypeScript
- Akun GitHub/Google (untuk OAuth testing)
- Editor (VS Code)
Install awal:
node -v npm -v
Buat project baru:
npx create-next-app@latest taskboard-auth --typescript --eslint --app cd taskboard-auth
Install dependency auth:
npm install next-auth zod bcryptjs npm install -D @types/bcryptjs
Kenapa package ini?
next-auth(Auth.js untuk Next.js): inti autentikasizod: validasi input agar aman dari payload anehbcryptjs: hashing password
3) Core Concepts — Fundamental yang Wajib Paham
Sebelum coding, pahami 5 konsep ini:
a) Authentication vs Authorization
- Authentication: “Kamu siapa?”
- Authorization: “Kamu boleh ngapain?”
Di tutorial ini fokus utama di authentication, tapi kita sentuh authorization dasar juga (protect route dashboard).
b) Session berbasis JWT
Kita pakai strategy jwt, artinya session disimpan dalam token terenkripsi/signed di cookie, bukan session tabel server-side. Cocok untuk skenario ringan-menengah dan deployment modern.
c) Password tidak pernah disimpan plaintext
Password harus di-hash (bcrypt) sebelum masuk database. Kalau database bocor, plaintext password tidak langsung terbaca.
d) Server-first di App Router
Di Next.js App Router, banyak logic auth sebaiknya dijalankan di server (Route Handler / Server Action), bukan di client.
e) Defense in Depth
Keamanan bukan satu tombol. Minimal gabungan:
- validasi input,
- hash password,
- secure cookie,
- proteksi route,
- error message yang tidak bocorkan detail.
4) Architecture / Diagram
Kita gunakan arsitektur sederhana berikut:
[Browser] | | 1) Login/Register Request v [Next.js Route Handler / Auth Endpoint] | | 2) Validate Input (Zod) | 3) Verify Hash (bcrypt) v [User Store (In-Memory / DB)] | | 4) Return user result v [Auth.js] | | 5) Issue JWT Session Cookie v [Protected Pages (/dashboard)]
Untuk demo runnable, kita pakai in-memory user store dulu agar cepat dipahami. Di production, ganti ke PostgreSQL + Prisma adapter.
5) Step-by-Step Implementation (Complete Runnable Code)
Semua file berikut bisa langsung dijalankan. Setelah itu, saya kasih cara test manual.
5.1 Struktur file
app/ api/ auth/ [...nextauth]/route.ts register/route.ts dashboard/page.tsx login/page.tsx register/page.tsx page.tsx lib/ auth.ts user-store.ts middleware.ts
5.2 Buat user store sederhana
lib/user-store.ts
// lib/user-store.ts import bcrypt from "bcryptjs"; export type UserRecord = { id: string; name: string; email: string; passwordHash: string; }; const users = new Map<string, UserRecord>(); export async function createUser(name: string, email: string, password: string) { const normalizedEmail = email.toLowerCase().trim(); if (users.has(normalizedEmail)) { throw new Error("EMAIL_ALREADY_EXISTS"); } const passwordHash = await bcrypt.hash(password, 12); const user: UserRecord = { id: crypto.randomUUID(), name, email: normalizedEmail, passwordHash, }; users.set(normalizedEmail, user); return { id: user.id, name: user.name, email: user.email }; } export async function findUserByEmail(email: string) { const normalizedEmail = email.toLowerCase().trim(); return users.get(normalizedEmail) ?? null; }
5.3 Konfigurasi Auth.js
lib/auth.ts
// lib/auth.ts import type { NextAuthConfig } from "next-auth"; import NextAuth from "next-auth"; import Credentials from "next-auth/providers/credentials"; import Google from "next-auth/providers/google"; import { z } from "zod"; import bcrypt from "bcryptjs"; import { findUserByEmail } from "@/lib/user-store"; const credentialsSchema = z.object({ email: z.string().email(), password: z.string().min(8), }); export const authConfig: NextAuthConfig = { session: { strategy: "jwt" }, pages: { signIn: "/login", }, providers: [ Google({ clientId: process.env.GOOGLE_CLIENT_ID ?? "", clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "", }), Credentials({ name: "Credentials", credentials: { email: { label: "Email", type: "email" }, password: { label: "Password", type: "password" }, }, async authorize(rawCredentials) { const parsed = credentialsSchema.safeParse(rawCredentials); if (!parsed.success) return null; const { email, password } = parsed.data; const user = await findUserByEmail(email); if (!user) return null; const isValid = await bcrypt.compare(password, user.passwordHash); if (!isValid) return null; return { id: user.id, name: user.name, email: user.email, }; }, }), ], callbacks: { async jwt({ token, user }) { if (user?.id) token.userId = user.id; return token; }, async session({ session, token }) { if (session.user && token.userId) { (session.user as { id?: string }).id = String(token.userId); } return session; }, }, secret: process.env.AUTH_SECRET, }; export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
5.4 Route handler untuk NextAuth
app/api/auth/[...nextauth]/route.ts
// app/api/auth/[...nextauth]/route.ts import { handlers } from "@/lib/auth"; export const { GET, POST } = handlers;
5.5 Endpoint register dengan error handling
app/api/register/route.ts
// app/api/register/route.ts import { NextResponse } from "next/server"; import { z } from "zod"; import { createUser } from "@/lib/user-store"; const registerSchema = z.object({ name: z.string().min(2), email: z.string().email(), password: z.string().min(8), }); export async function POST(req: Request) { try { const body = await req.json(); const parsed = registerSchema.safeParse(body); if (!parsed.success) { return NextResponse.json( { error: "Input tidak valid", details: parsed.error.flatten() }, { status: 400 } ); } const { name, email, password } = parsed.data; const user = await createUser(name, email, password); return NextResponse.json({ message: "Registrasi berhasil", user }, { status: 201 }); } catch (err) { if (err instanceof Error && err.message === "EMAIL_ALREADY_EXISTS") { return NextResponse.json({ error: "Email sudah terdaftar" }, { status: 409 }); } console.error("REGISTER_ERROR", err); return NextResponse.json( { error: "Terjadi kesalahan server, coba lagi" }, { status: 500 } ); } }
5.6 Halaman register
app/register/page.tsx
"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; export default function RegisterPage() { const router = useRouter(); const [form, setForm] = useState({ name: "", email: "", password: "" }); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); async function handleSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setError(""); setLoading(true); try { const res = await fetch("/api/register", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), }); const data = await res.json(); if (!res.ok) { setError(data.error ?? "Registrasi gagal"); return; } router.push("/login?registered=1"); } catch { setError("Network error. Cek koneksi internet kamu."); } finally { setLoading(false); } } return ( <main style={{ maxWidth: 420, margin: "40px auto" }}> <h1>Register</h1> <form onSubmit={handleSubmit}> <input placeholder="Nama" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required /> <br /> <input type="email" placeholder="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required /> <br /> <input type="password" placeholder="Password (min 8)" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required minLength={8} /> <br /> <button type="submit" disabled={loading}> {loading ? "Menyimpan..." : "Daftar"} </button> </form> {error && <p style={{ color: "red" }}>{error}</p>} </main> ); }
5.7 Halaman login + Google sign-in
app/login/page.tsx
"use client"; import { useState } from "react"; import { signIn } from "next-auth/react"; export default function LoginPage() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); async function onSubmit(e: React.FormEvent<HTMLFormElement>) { e.preventDefault(); setLoading(true); setError(""); const result = await signIn("credentials", { email, password, redirect: false, callbackUrl: "/dashboard", }); setLoading(false); if (result?.error) { setError("Email/password salah atau akun belum terdaftar."); return; } window.location.href = "/dashboard"; } return ( <main style={{ maxWidth: 420, margin: "40px auto" }}> <h1>Login</h1> <form onSubmit={onSubmit}> <input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> <br /> <input type="password" placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required /> <br /> <button type="submit" disabled={loading}> {loading ? "Memproses..." : "Login"} </button> </form> <hr /> <button onClick={() => signIn("google", { callbackUrl: "/dashboard" })}> Login dengan Google </button> {error && <p style={{ color: "red" }}>{error}</p>} </main> ); }
5.8 Protect dashboard di server
app/dashboard/page.tsx
import { auth, signOut } from "@/lib/auth"; import { redirect } from "next/navigation"; export default async function DashboardPage() { const session = await auth(); if (!session?.user) { redirect("/login"); } return ( <main style={{ maxWidth: 720, margin: "40px auto" }}> <h1>Dashboard</h1> <p>Halo, {session.user.name ?? session.user.email}</p> <p>Selamat! Route ini hanya bisa diakses user yang login.</p> <form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} > <button type="submit">Logout</button> </form> </main> ); }
5.9 Middleware untuk hard-guard route
middleware.ts
export { auth as middleware } from "@/lib/auth"; export const config = { matcher: ["/dashboard/:path*"], };
5.10 Environment variables
.env.local
AUTH_SECRET=super-long-random-secret-at-least-32-chars GOOGLE_CLIENT_ID=isi-dari-google-console GOOGLE_CLIENT_SECRET=isi-dari-google-console NEXTAUTH_URL=http://localhost:3000
Generate secret cepat:
openssl rand -base64 32
5.11 Jalankan dan test
npm run dev
Skenario test:
- Buka
/register, daftar user baru - Login via credentials
- Akses
/dashboardsukses - Logout
- Coba akses
/dashboardlagi tanpa login (harus diarahkan ke/login)
6) Best Practices — Tips dari Praktik Industri
Berikut checklist yang sebaiknya kamu terapkan saat pindah ke production:
- Gunakan database + adapter resmi
- Misal PostgreSQL + Prisma Adapter.
- Aktifkan HTTPS di semua environment publik
- Cookie auth wajib secure.
- Set up rate limiting di endpoint auth
- Cegah brute force.
- Tambahkan CSRF protection dan same-site cookie policy
- Kurangi risiko request jahat lintas situs.
- Minimalisasi data di JWT/session
- Jangan simpan data sensitif berlebihan.
- Gunakan observability
- Logging auth failures, suspicious login, dsb.
- Routine dependency updates
- Auth stack cepat berubah; patch security jangan ditunda.
7) Common Mistakes — Yang Sering Kejadiannya Mahal
❌ Menyimpan password plaintext
Ini red flag paling besar. Selalu hash + salt.
❌ Error message terlalu detail
Contoh buruk: “Email benar, password salah”. Ini mempermudah enumerasi akun. Lebih aman: “Kredensial tidak valid”.
❌ Semua logic auth dikerjakan client-side
Validasi di client itu UX, bukan security boundary. Keamanan harus enforce di server.
❌ Secret disimpan di repo
.env.local jangan pernah ke-commit. Gunakan secret manager di CI/CD.
❌ Tidak melakukan upgrade saat security advisory keluar
Di ekosistem Next.js pernah ada advisory kritikal terkait RSC protocol. Pastikan proses patching jelas (SLA patch security).
8) Advanced Tips — Buat Kamu yang Mau Lebih Dalam
Kalau fondasi dasar sudah stabil, lanjutkan ke level berikut:
- Role-Based Access Control (RBAC)
- Tambahkan role (
admin,editor,viewer) di JWT callback.
- Tambahkan role (
- Multi-factor Authentication (MFA)
- Kombinasikan password + OTP / passkey.
- Session hardening
- Rotasi token periodik, force sign-out untuk perangkat mencurigakan.
- Audit trail
- Catat event: login sukses, gagal, reset password, revoke session.
- Zero Trust untuk endpoint internal
- Jangan anggap request dari internal network otomatis aman.
- Data Access Layer
- Pisahkan business logic agar data sensitif tidak bocor ke Client Component.
Contoh callback role (potongan):
callbacks: { async jwt({ token, user }) { if (user) { token.role = (user as { role?: string }).role ?? "viewer"; } return token; }, async session({ session, token }) { if (session.user) { (session.user as { role?: string }).role = String(token.role ?? "viewer"); } return session; }, }
Lalu di dashboard admin, enforce role di server sebelum render.
9) Summary & Next Steps
Kita sudah membangun sistem auth modern dengan Next.js 16 + Auth.js, lengkap dari register, login, OAuth, proteksi route, sampai error handling.
Takeaways utama:
- Auth harus dirancang dari awal, bukan ditempel belakangan.
- Validasi server-side + hash password + middleware protection adalah baseline.
- Next.js App Router memungkinkan pola auth yang lebih rapi dan aman karena server-first.
Next steps yang saya sarankan:
- Migrasi in-memory store ke PostgreSQL + Prisma
- Tambahkan reset password flow
- Tambahkan email verification
- Implement rate limiting dan observability
- Buat automated security tests untuk auth endpoints
Kalau kamu konsisten dengan pola ini, kamu bukan cuma “punya login”, tapi punya fondasi keamanan yang layak dipakai untuk produk beneran.
10) References
- Next.js Documentation: https://nextjs.org/docs
- Next.js
llms.txtindex (v16.x): https://nextjs.org/docs/llms.txt - Next.js Blog (rilis, security updates): https://nextjs.org/blog
- Auth.js Getting Started: https://authjs.dev/getting-started
- Next.js GitHub Examples: https://github.com/vercel/next.js/tree/canary/examples
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- NIST Digital Identity Guidelines: https://pages.nist.gov/800-63-3/