PVR Tech Studio
Server actions and auth

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.

5 min read
Updated August 17, 2026

Overview

Three pieces, each doing one job:

PieceWhereJob
Server Actionssrc/app/actions/auth.tsValidate a submission and create or destroy the session
Session cookiesrc/lib/session.tsAn httpOnly cookie the server can trust
Route guardsrc/proxy.tsRedirect 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

FileRole
src/app/actions/auth.tsSix Server Actions: sign-in, register, forgot, reset, unlock, sign-out
src/lib/session.tsSESSION_COOKIE, createSession, destroySession, hasSession
src/proxy.tsLocale negotiation, then the guard
src/platform/auth.tsuseAuthSubmit() and signOut(), the seam the shared views call
src/components/auth/validators.tsPure 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=true

Every 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): void

onDone 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):

FunctionPurpose
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:

  1. createSession in src/lib/session.ts — call your sign-in endpoint and store what it returns (a JWT, a session id) instead of the demo marker.
  2. 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.

Was this page helpful?