dailytutorfor.you
Web Development

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.

11 menit baca

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:

  1. Node.js 20+
  2. npm/pnpm/yarn (contoh di tutorial ini pakai npm)
  3. Basic React dan JavaScript/TypeScript
  4. Akun GitHub/Google (untuk OAuth testing)
  5. 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 autentikasi
  • zod: validasi input agar aman dari payload aneh
  • bcryptjs: 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:

  1. Buka /register, daftar user baru
  2. Login via credentials
  3. Akses /dashboard sukses
  4. Logout
  5. Coba akses /dashboard lagi tanpa login (harus diarahkan ke /login)

6) Best Practices — Tips dari Praktik Industri

Berikut checklist yang sebaiknya kamu terapkan saat pindah ke production:

  1. Gunakan database + adapter resmi
    • Misal PostgreSQL + Prisma Adapter.
  2. Aktifkan HTTPS di semua environment publik
    • Cookie auth wajib secure.
  3. Set up rate limiting di endpoint auth
    • Cegah brute force.
  4. Tambahkan CSRF protection dan same-site cookie policy
    • Kurangi risiko request jahat lintas situs.
  5. Minimalisasi data di JWT/session
    • Jangan simpan data sensitif berlebihan.
  6. Gunakan observability
    • Logging auth failures, suspicious login, dsb.
  7. 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:

  1. Role-Based Access Control (RBAC)
    • Tambahkan role (admin, editor, viewer) di JWT callback.
  2. Multi-factor Authentication (MFA)
    • Kombinasikan password + OTP / passkey.
  3. Session hardening
    • Rotasi token periodik, force sign-out untuk perangkat mencurigakan.
  4. Audit trail
    • Catat event: login sukses, gagal, reset password, revoke session.
  5. Zero Trust untuk endpoint internal
    • Jangan anggap request dari internal network otomatis aman.
  6. 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:

  1. Migrasi in-memory store ke PostgreSQL + Prisma
  2. Tambahkan reset password flow
  3. Tambahkan email verification
  4. Implement rate limiting dan observability
  5. 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