Authentication
Authentication with NextAuth.js
osztromok.com has one admin — you. No OAuth, no user registrations, no password
reset emails. Just a username + password backed by bcrypt, a secure session cookie,
and middleware that protects the entire (admin) route group.
Chapter milestone: Visiting /admin without a session
redirects to /login. A correct username/password sets a signed JWT cookie
and redirects back. Server Components and Server Actions can both read the session.
An incorrect password returns a clear error message.
Why credentials-only auth for a personal site
OAuth providers (Google, GitHub…)
- Ideal for multi-user apps where anyone should sign up
- Requires registering an OAuth app for every provider
- Callback URLs need updating on every domain change
- Overkill when there's exactly one admin
Credentials provider ✓ ours
- Username + password stored in
.env— no DB table needed - Password hashed with bcrypt — never stored in plain text
- Single admin, so no multi-user complexity
- Session stored as a signed JWT cookie — stateless
next-auth (v4) to
the beta next-auth@5 which has first-class App Router support.
The API is significantly cleaner: one auth.ts config file,
auth() replaces getServerSession(), and middleware is one line.
Install packages and generate secrets
# NextAuth.js v5 (App Router native)
npm install next-auth@beta
# bcryptjs — pure JS bcrypt, no native binaries to compile
npm install bcryptjs
npm install -D @types/bcryptjs
# Generate a cryptographically random AUTH_SECRET (required by NextAuth)
npx auth secret
# Prints something like: AUTH_SECRET="abc123...64chars"
# Copy this into your .env.local
# Hash your admin password — run this once, copy the output
node -e "
const bcrypt = require('bcryptjs');
bcrypt.hash('your-password-here', 12).then(h => console.log(h));
"
# Output: $2a$12$randomsalt... ← copy this into .env.local as ADMIN_PASSWORD_HASH
# Generated by: npx auth secret
AUTH_SECRET="your-64-char-random-string"
# Admin credentials — never the plain text password
ADMIN_USERNAME="admin"
ADMIN_PASSWORD_HASH="$2a$12$your-bcrypt-hash-here"
# Where to redirect after login (optional, defaults to /)
NEXTAUTH_URL="http://localhost:3000"
auth.ts — the single source of truth
In NextAuth v5 everything lives in one auth.ts file at the project root.
It exports auth (read the session), signIn, signOut,
and handlers (the API route). You import from @/auth everywhere.
import NextAuth from 'next-auth'
import Credentials from 'next-auth/providers/credentials'
import { compare } from 'bcryptjs'
import { z } from 'zod'
// ── Validate the login form fields with Zod ────────────────────
const LoginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
})
export const { handlers, auth, signIn, signOut } = NextAuth({
// ── Session strategy ─────────────────────────────────────────
// JWT: session data stored in an encrypted cookie — no DB table needed.
session: { strategy: 'jwt' },
// ── Custom pages ──────────────────────────────────────────────
pages: {
signIn: '/login', // redirect here instead of NextAuth's default page
},
// ── Callbacks — shape the JWT and session ────────────────────
callbacks: {
jwt({ token, user }) {
// On sign-in, user is populated. Add the role to the token.
if (user) token.role = user.role
return token
},
session({ session, token }) {
// Expose role in session.user so components can read it.
if (token.role) session.user.role = token.role as string
return session
},
},
// ── Providers ────────────────────────────────────────────────
providers: [
Credentials({
credentials: {
username: { label: 'Username', type: 'text' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// Validate shape first
const parsed = LoginSchema.safeParse(credentials)
if (!parsed.success) return null
const { username, password } = parsed.data
// Check username
if (username !== process.env.ADMIN_USERNAME) return null
// Compare password against stored hash — timing-safe
const hash = process.env.ADMIN_PASSWORD_HASH ?? ''
const valid = await compare(password, hash)
if (!valid) return null
// Return the user object — this becomes token.user in the JWT callback
return { id: '1', name: 'Admin', email: '', role: 'admin' }
},
}),
],
})
null from authorize(), never throw.
Returning null tells NextAuth the credentials were wrong — it redirects
back to the login page with an ?error=CredentialsSignin param.
Throwing an error shows a generic crash page instead.
Extending the Session and JWT types
By default session.user only has name, email,
and image. We added a role field — TypeScript doesn't know
about it yet. Use module augmentation to extend the NextAuth types:
import 'next-auth'
import 'next-auth/jwt'
declare module 'next-auth' {
interface User {
role?: string
}
interface Session {
user: User & {
name?: string | null
email?: string | null
role?: string
}
}
}
declare module 'next-auth/jwt' {
interface JWT {
role?: string
}
}
The NextAuth API route handler
NextAuth needs a catch-all Route Handler to process sign-in, sign-out, and callback requests. In v5 this is just three lines:
import { handlers } from '@/auth'
export const { GET, POST } = handlers
// That's it. NextAuth handles:
// GET /api/auth/session → returns current session as JSON
// POST /api/auth/signin → processes credentials sign-in
// POST /api/auth/signout → clears the session cookie
// GET /api/auth/csrf → returns CSRF token
middleware.ts — protecting /admin in one place
We started a basic middleware in Chapter 7 for API token checks.
Now we replace it with the NextAuth middleware that guards the entire
(admin) route group. Any visitor without a valid session is
redirected to /login automatically.
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export default auth((req) => {
const { pathname } = req.nextUrl
const isLoggedIn = !!req.auth // req.auth is the session, null if not logged in
// ── Protect all /admin routes ─────────────────────────────────
if (pathname.startsWith('/admin') && !isLoggedIn) {
const loginUrl = new URL('/login', req.url)
loginUrl.searchParams.set('callbackUrl', pathname)
return NextResponse.redirect(loginUrl)
}
// ── Redirect logged-in users away from /login ─────────────────
if (pathname === '/login' && isLoggedIn) {
return NextResponse.redirect(new URL('/admin', req.url))
}
// ── Protect admin API routes ──────────────────────────────────
if (pathname.startsWith('/api/admin') && !isLoggedIn) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
})
export const config = {
// Run middleware on all routes except static assets
matcher: ['/((?!_next/static|_next/image|favicon.ico|resources).*)'],
}
callbackUrl param makes the login flow feel polished.
If a user bookmarks /admin/pages/edit/42 and their session expires,
they'll land on /login?callbackUrl=/admin/pages/edit/42.
After signing in, NextAuth reads this param and redirects them straight back.
No need to implement this yourself — NextAuth handles it automatically.
app/login/page.tsx — the login form
'use server'
import { signIn, signOut } from '@/auth'
import { AuthError } from 'next-auth'
export async function loginAction(formData: FormData) {
try {
await signIn('credentials', {
username: formData.get('username'),
password: formData.get('password'),
redirectTo: formData.get('callbackUrl') as string || '/admin',
})
} catch (err) {
// NextAuth throws a redirect internally — let those through!
if (err instanceof AuthError) {
switch (err.type) {
case 'CredentialsSignin':
return { error: 'Invalid username or password.' }
default:
return { error: 'Something went wrong. Please try again.' }
}
}
throw err // re-throw the redirect so NextAuth can process it
}
}
export async function logoutAction() {
await signOut({ redirectTo: '/' })
}
import type { Metadata } from 'next'
import LoginForm from './LoginForm'
export const metadata: Metadata = { title: 'Login' }
export default function LoginPage({
searchParams,
}: {
searchParams: { callbackUrl?: string; error?: string }
}) {
return (
<div className="flex min-h-[70vh] items-center justify-center px-4">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-bold text-site-text mb-2 text-center">Admin login</h1>
<p className="text-sm text-site-dim text-center mb-8">osztromok.com</p>
<LoginForm
callbackUrl={searchParams.callbackUrl}
urlError={searchParams.error}
/>
</div>
</div>
)
}
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { loginAction } from '@/actions/auth'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="w-full badge-accent py-2 rounded text-sm font-semibold
disabled:opacity-50 transition-opacity"
>
{pending ? 'Signing in…' : 'Sign in'}
</button>
)
}
interface Props { callbackUrl?: string; urlError?: string }
export default function LoginForm({ callbackUrl, urlError }: Props) {
const [state, formAction] = useFormState(loginAction, null)
const errorMsg =
state?.error ||
(urlError === 'CredentialsSignin' ? 'Invalid username or password.' : null)
return (
<form action={formAction} className="card p-6 space-y-4">
<input type="hidden" name="callbackUrl" value={callbackUrl ?? '/admin'} />
{errorMsg && (
<div className="bg-red-500/10 border border-red-500/30 rounded px-3 py-2
text-sm text-red-400 text-center">
{errorMsg}
</div>
)}
<div>
<label className="block text-sm text-site-muted mb-1">Username</label>
<input
name="username"
type="text"
autoComplete="username"
className="w-full bg-site-deep border border-site-border rounded px-3 py-2
text-sm text-site-text focus:outline-none focus:border-site-accent"
required
/>
</div>
<div>
<label className="block text-sm text-site-muted mb-1">Password</label>
<input
name="password"
type="password"
autoComplete="current-password"
className="w-full bg-site-deep border border-site-border rounded px-3 py-2
text-sm text-site-text focus:outline-none focus:border-site-accent"
required
/>
</div>
<SubmitButton />
</form>
)
}
Reading the session everywhere it's needed
The auth() function from @/auth returns the current session
in any context — Server Components, Route Handlers, and Server Actions.
The method differs slightly depending on context.
| Context | How to read the session | Notes |
|---|---|---|
| Server Component | const session = await auth() |
Returns null if not logged in. No hooks needed. |
| Route Handler | const session = await auth() |
Same call — works in any async server context. |
| Server Action | const session = await auth() |
Always re-check inside actions — never trust the client to pass the role. |
| Client Component | const { data } = useSession() |
Requires wrapping root layout with <SessionProvider>. Use sparingly — prefer passing session data as props from Server Components. |
| Middleware | req.auth |
Available when using auth() as the middleware function (as we do). |
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { logoutAction } from '@/actions/auth'
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth()
// Belt-and-suspenders check — middleware should have handled this already
if (!session) redirect('/login')
return (
<div className="min-h-screen">
{/* Admin top bar */}
<header className="bg-site-deep border-b border-site-border px-6 py-3
flex items-center justify-between">
<span className="text-sm font-semibold text-site-text">
Admin — osztromok.com
</span>
<div className="flex items-center gap-4">
<span className="text-xs text-site-dim">
{session.user.name}
</span>
<form action={logoutAction}>
<button
type="submit"
className="text-xs text-site-dim hover:text-site-text transition-colors"
>
Sign out
</button>
</form>
</div>
</header>
<main className="max-w-site mx-auto px-6 py-8">
{children}
</main>
</div>
)
}
'use server'
import { auth } from '@/auth'
import { prisma } from '@/lib/prisma'
import { PageContentSchema } from '@/lib/schemas'
export async function deletePageContent(id: number) {
// Always re-verify auth inside every sensitive Server Action.
// Never rely on the admin layout redirect alone —
// a race condition or direct POST could bypass the UI check.
const session = await auth()
if (!session || session.user.role !== 'admin') {
throw new Error('Unauthorized')
}
await prisma.pageContent.delete({ where: { id } })
}
The complete auth flow end-to-end
Test every auth scenario
Visiting /admin unauthenticated redirects to /login
Open an incognito window, go to http://localhost:3000/admin.
You should land on /login?callbackUrl=/admin. The URL bar
should show the callbackUrl param.
Wrong password shows an inline error
On the login page, enter any wrong password. The red error box should appear inside the form — no page reload, no redirect to an error page.
Correct password redirects back to /admin
Enter the correct credentials. You should land on /admin.
The admin top bar should show "Admin" and a "Sign out" button.
Visiting /login while logged in redirects to /admin
While logged in, manually navigate to /login. Middleware should
redirect you straight to /admin.
Sign out clears the session
Click "Sign out". You should be redirected to /. Attempting
to visit /admin again should redirect back to login.
Direct call to a guarded Server Action throws
Temporarily call deletePageContent(1) from a public page without
a session. You should see an "Unauthorized" error caught by the
error.tsx boundary — not a silent success.
saveContentSection Server Action,
and drag-and-drop position reordering. This is where the content management loop closes.