Authentication & Route Guard
Sign-in is a real server round trip: a Server Action validates the submission, writes an httpOnly session cookie no client script can read, and a request-time guard can reject an unauthenticated visitor before any HTML is generated.
Overview
Three pieces, each doing one job:
| Piece | Where | Job |
|---|---|---|
| Server Actions | src/app/actions/auth.ts | Validate a submission and create or destroy the session |
| Session cookie | src/lib/session.ts | An httpOnly cookie the server can trust |
| Route guard | src/proxy.ts | Redirect unauthenticated requests before rendering |
Doing the work on the server buys three things a click handler cannot: validation happens where it can be trusted, the cookie is unreadable from script, and the form still submits with JavaScript disabled.
The shared auth screens are unchanged from the client-only edition — same components, same validators,
same toasts. Only the submit path differs, and it is swapped behind @/platform/auth.
Architecture & files
| File | Role |
|---|---|
src/app/actions/auth.ts | Six Server Actions: sign-in, register, forgot, reset, unlock, sign-out |
src/lib/session.ts | SESSION_COOKIE, createSession, destroySession, hasSession |
src/proxy.ts | Locale negotiation, then the guard |
src/platform/auth.ts | useAuthSubmit() and signOut(), the seam the shared views call |
src/components/auth/validators.ts | Pure validators, shared with the client |
One definition of "valid"
The Server Actions call the same validators.ts the client-side views use. Those functions take plain
values and return i18n keys, with no React or DOM dependency, so there is a single definition of validity
rather than a server copy that silently drifts from the client one.
export async function signInAction(values: {email: string; password: string}): Promise<AuthResult> {
const errors = validateLogin(values)
if (Object.keys(errors).length) return fail(errors)
// A real deployment authenticates here and stores what the backend returns.
await createSession(values.email)
return {ok: true}
}Usage
Wiring a screen to an action
The shared views already do this. useAuthSubmit() takes the action name and the submitted values:
const {loading, run} = useAuthSubmit()
run(
() => {
// Success only. Toast, redirect, whatever the screen does.
},
'login',
{email, password},
)The action name selects the Server Action from a lookup in src/platform/auth.ts. A name with no entry
falls back to a no-op success, which is how a presentational screen keeps working without a backend.
Signing out
import {signOut} from '@/platform/auth'
await signOut()This has to be a server call: the cookie is httpOnly, so no client script can clear it.
Turning the guard on
# .env.local
AUTH_REQUIRED=trueEvery in-shell route now requires a session. /auth/* stays public, and an unauthenticated request is
redirected to /<locale>/auth/login?next=<original-path>.
API / Props
useAuthSubmit() → {loading, run}
run(onDone: () => void, action?: string, payload?: AuthPayload): voidonDone fires only on success. On failure the shared view has already surfaced its own client-side
validation errors — from the same validators — so there is nothing extra to render.
Server Actions — each returns AuthResult:
export interface AuthResult {
ok: boolean
/** field → i18n key, exactly as the client-side validators return. */
errors?: AuthErrors
}Session helpers (src/lib/session.ts):
| Function | Purpose |
|---|---|
createSession(email) | Sets the httpOnly cookie, one-week expiry |
destroySession() | Deletes it |
hasSession() | Presence check, for a Server Component |
The cookie is httpOnly, sameSite: 'lax', path: '/', and secure in production only — so it
still works over plain HTTP on localhost.
Configuration & customization
Connecting a real backend
Two edits, and nothing around them changes:
createSessioninsrc/lib/session.ts— call your sign-in endpoint and store what it returns (a JWT, a session id) instead of the demo marker.- The cookie check in
src/proxy.ts— replace the presence test with real verification: a signature check, or a session lookup.
The redirect, the next round-trip, the sign-out action and the screens all keep working.
Why the guard is off by default
This is a template, and its hosted preview has to be browsable — a demo that demands a login before showing anything is useless as a demo. So the capability ships complete but dormant. For a real deployment, set the variable.
Notes & gotchas
AUTH_REQUIRED is a runtime value, not a build-time one. It is read when the server starts, so you
can flip it by restarting with a different value — no rebuild. This is the opposite of the
NEXT_PUBLIC_* variables, which are compiled into the bundle. Getting the two confused is the most
common deployment mistake; see Deploying the Next.js Edition.
Locale resolution has to run before the guard. Both live in one file because Next runs exactly one request-time handler and the order matters: the locale must be settled first, or an unauthenticated visitor gets redirected to a bare path that then has to bounce again to acquire its locale. The guard also returns early if locale negotiation already produced a redirect.
The file is proxy.ts, not middleware.ts. Next 16 renamed the convention and warns on the old name.
The guard matches on the de-localised path. /ja/auth/login is tested as /auth/login, so the public
allowlist needs one entry rather than one per locale.
The demo session is not a credential. The stored value is an opaque marker, not a verified token, because there is no auth backend to verify against. Treat it as plumbing that works, not as security.
Password reset reports success for unknown addresses on purpose. Telling a caller whether an email exists is an account-enumeration leak.
A cookie-stripping proxy breaks this completely. If something between the browser and the app removes cookies, the guard never sees a session and redirects every request to the login page — including, in effect, the login page's own destination. The same constraint that governs the first paint applies here.
Related
Was this page helpful?
