dailytutorfor.you
Web Development

Next.js 16 + Auth.js in 2026: Complete Guide to Building Secure Login for Web Applications

A complete Indonesian-to-English tutorial for building modern authentication in Next.js 16 with Auth.js. Covers architecture, runnable implementation, best practices, common mistakes, and production-grade tips.

11 min read

Next.js 16 + Auth.js in 2026: Build a Secure, Fast, and Production-Ready Login System

1) Introduction — What and Why

If you build a web application in 2026, the first question from users is almost always: "Can I sign in with Google?", followed by the second question from the team: "Is it secure?".

On the other hand, developers are also expected to move fast. It is not enough that it "works on localhost"; it also has to be:

  • secure for production,
  • high performance,
  • scalable,
  • easy to maintain as the team grows.

This is where the combination of Next.js 16 (App Router) + Auth.js becomes highly relevant. Next.js provides the foundation for modern full-stack web apps (SSR, Server Components, Route Handlers, caching, middleware), while Auth.js simplifies authentication flows such as OAuth, credentials, magic links, and passkeys.

In this tutorial, we will build a mini "TaskBoard" app with these features:

  • Register + login with email/password
  • Google OAuth login
  • JWT-based session management
  • Route protection (dashboard page only for logged-in users)
  • Proper error handling
  • A code structure ready to scale

Why does this matter in the real world? Imagine you are building:

  • an internal company dashboard,
  • a SaaS product for SMBs,
  • a learning platform,
  • an e-commerce admin portal.

All of these need authentication done correctly from the start. Wrong auth design is expensive: security bugs, user lockouts, and even data leaks.

Simple analogy: auth is like an office door + receptionist + access card. If the door is strong but the receptionist gives cards carelessly, it is still dangerous.


2) Prerequisites — What You Need to Prepare

Before starting, make sure you have:

  1. Node.js 20+
  2. npm/pnpm/yarn (this tutorial uses npm examples)
  3. Basic React and JavaScript/TypeScript knowledge
  4. A GitHub/Google account (for OAuth testing)
  5. An editor (VS Code)

Initial install checks:

node -v npm -v

Create a new project:

npx create-next-app@latest taskboard-auth --typescript --eslint --app cd taskboard-auth

Install auth dependencies:

npm install next-auth zod bcryptjs npm install -D @types/bcryptjs

Why these packages?

  • next-auth (Auth.js for Next.js): authentication core
  • zod: input validation to protect against malformed payloads
  • bcryptjs: password hashing

3) Core Concepts — Fundamentals You Must Understand

Before coding, understand these 5 concepts:

a) Authentication vs Authorization

  • Authentication: "Who are you?"
  • Authorization: "What are you allowed to do?"

This tutorial mainly focuses on authentication, but we also touch basic authorization (protecting the dashboard route).

b) JWT-based sessions

We use the jwt strategy, meaning the session is stored in a signed/encrypted token cookie, not in a server-side session table. This fits light-to-medium scenarios and modern deployments.

c) Passwords are never stored in plaintext

Passwords must be hashed (bcrypt) before entering the database. If the database leaks, plaintext passwords are not directly exposed.

d) Server-first in App Router

In Next.js App Router, most auth logic should run on the server (Route Handler / Server Action), not on the client.

e) Defense in Depth

Security is not one button. At minimum, combine:

  • input validation,
  • password hashing,
  • secure cookies,
  • route protection,
  • error messages that do not leak details.

4) Architecture / Diagram

We use the following simple architecture:

[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)]

For a runnable demo, we use an in-memory user store first so it is easy to understand quickly. In production, replace it with PostgreSQL + Prisma adapter.


5) Step-by-Step Implementation (Complete Runnable Code)

All files below can run directly. After that, I will provide manual testing steps.

5.1 File structure

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 Create a simple user store

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 Configure 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 for 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 Register endpoint with 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: "Invalid input", details: parsed.error.flatten() }, { status: 400 } ); } const { name, email, password } = parsed.data; const user = await createUser(name, email, password); return NextResponse.json({ message: "Registration successful", user }, { status: 201 }); } catch (err) { if (err instanceof Error && err.message === "EMAIL_ALREADY_EXISTS") { return NextResponse.json({ error: "Email is already registered" }, { status: 409 }); } console.error("REGISTER_ERROR", err); return NextResponse.json( { error: "Server error occurred, please try again" }, { status: 500 } ); } }

5.6 Register page

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 ?? "Registration failed"); return; } router.push("/login?registered=1"); } catch { setError("Network error. Check your internet connection."); } finally { setLoading(false); } } return ( <main style={{ maxWidth: 420, margin: "40px auto" }}> <h1>Register</h1> <form onSubmit={handleSubmit}> <input placeholder="Name" 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 ? "Saving..." : "Sign Up"} </button> </form> {error && <p style={{ color: "red" }}>{error}</p>} </main> ); }

5.7 Login page + 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("Wrong email/password or account not registered."); 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 ? "Processing..." : "Login"} </button> </form> <hr /> <button onClick={() => signIn("google", { callbackUrl: "/dashboard" })}> Login with Google </button> {error && <p style={{ color: "red" }}>{error}</p>} </main> ); }

5.8 Protect dashboard on the 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>Hello, {session.user.name ?? session.user.email}</p> <p>Congrats! This route can only be accessed by logged-in users.</p> <form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} > <button type="submit">Logout</button> </form> </main> ); }

5.9 Middleware for 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=fill-from-google-console GOOGLE_CLIENT_SECRET=fill-from-google-console NEXTAUTH_URL=http://localhost:3000

Generate a quick secret:

openssl rand -base64 32

5.11 Run and test

npm run dev

Test scenarios:

  1. Open /register, register a new user
  2. Login via credentials
  3. Access /dashboard successfully
  4. Logout
  5. Try accessing /dashboard again without login (must redirect to /login)

6) Best Practices — Tips from Industry Practice

Here is a checklist you should apply when moving to production:

  1. Use a database + official adapter
    • For example PostgreSQL + Prisma Adapter.
  2. Enable HTTPS in all public environments
    • Auth cookies must be secure.
  3. Set up rate limiting on auth endpoints
    • Prevent brute force attacks.
  4. Add CSRF protection and same-site cookie policy
    • Reduce malicious cross-site request risks.
  5. Minimize data in JWT/session
    • Do not store excessive sensitive data.
  6. Use observability
    • Log auth failures, suspicious logins, etc.
  7. Routine dependency updates
    • Auth stack changes quickly; do not delay security patches.

7) Common Mistakes — Frequent and Costly

❌ Storing plaintext passwords

This is the biggest red flag. Always hash + salt.

❌ Overly detailed error messages

Bad example: “Email correct, password wrong”. This makes account enumeration easier. Safer: “Invalid credentials”.

❌ Putting all auth logic on the client side

Client validation is UX, not a security boundary. Security must be enforced on the server.

❌ Keeping secrets in the repository

Never commit .env.local. Use a secret manager in CI/CD.

❌ Not upgrading when security advisories are released

There has been a critical advisory in the Next.js ecosystem related to the RSC protocol. Ensure your patching process is clear (security patch SLA).


8) Advanced Tips — For Going Deeper

If your basic foundation is stable, continue to the next level:

  1. Role-Based Access Control (RBAC)
    • Add roles (admin, editor, viewer) in the JWT callback.
  2. Multi-factor Authentication (MFA)
    • Combine password + OTP / passkey.
  3. Session hardening
    • Rotate tokens periodically, force sign-out for suspicious devices.
  4. Audit trail
    • Record events: successful login, failed login, password reset, session revoke.
  5. Zero Trust for internal endpoints
    • Do not assume requests from internal networks are automatically safe.
  6. Data Access Layer
    • Separate business logic so sensitive data does not leak to Client Components.

Role callback example (snippet):

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; }, }

Then in the admin dashboard, enforce role checks on the server before rendering.


9) Summary & Next Steps

We have built a modern auth system with Next.js 16 + Auth.js, complete from registration, login, OAuth, route protection, and error handling.

Key takeaways:

  • Auth must be designed from the beginning, not added later.
  • Server-side validation + password hashing + middleware protection is the baseline.
  • Next.js App Router enables cleaner and more secure auth patterns with a server-first approach.

Next steps I recommend:

  1. Migrate in-memory store to PostgreSQL + Prisma
  2. Add a password reset flow
  3. Add email verification
  4. Implement rate limiting and observability
  5. Create automated security tests for auth endpoints

If you stay consistent with this pattern, you will not just “have login,” but a security foundation suitable for real products.


10) References