Modern Web · Nextjs

Next.js 15 Authentication — A Field Guide

How to structure authentication in a Next.js 15 App Router project: middleware session checks, server actions for login and logout, cookie sessions vs JWTs, and where Auth.js or Lucia actually save you work.

John Kihiu12 min read

Authentication in the App Router is less about a library and more about where you put three checks: is there a session, is it valid, and does this route require one. Next.js 15 gives you middleware, Server Actions, and Server Components to do this, and the main design decision is how much of it you hand to a library like Auth.js or Lucia versus how much you write yourself against a plain cookie session.

Middleware does the cheap first pass, not the whole job

Middleware runs on the Edge runtime before a request reaches a route, which makes it the right place for a fast, cookie-presence check that redirects unauthenticated users away from protected routes — it should not be where you validate a session against a database, because middleware can't reliably reach most databases and adds latency to every single request if it tries. The pattern that holds up is: middleware checks that a session cookie exists and looks well-formed, then the actual page or layout re-verifies it against your session store or JWT signature. Treat middleware as a bouncer checking for a wristband, not the person checking your ID against a guest list.

TSX · middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const PROTECTED_PREFIXES = ['/dashboard', '/settings'];

export function middleware(request: NextRequest) {
  const sessionCookie = request.cookies.get('session')?.value;
  const isProtected = PROTECTED_PREFIXES.some((p) =>
    request.nextUrl.pathname.startsWith(p)
  );

  if (isProtected && !sessionCookie) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('from', request.nextUrl.pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/settings/:path*'],
};

Server actions for login and logout

Login and logout are natural fits for Server Actions: a form posts directly to a server function, no separate API route or client-side fetch wiring required. The action verifies credentials, creates the session (a signed cookie or a row in a sessions table), and calls cookies().set(...) before redirecting. Because the action runs on the server, it can set an httpOnly, secure cookie directly — the browser never sees or can script access to the session token, which closes off a whole class of XSS-driven session theft that's a real risk with client-readable JWTs stored in localStorage.

httpOnly cookies over localStorage, every time

Storing a session token in localStorage makes it readable by any script running on the page, including an injected one from a compromised dependency. An httpOnly cookie set from a Server Action is invisible to JavaScript entirely — the browser attaches it automatically and no client code ever touches it.

A stateful session (an opaque cookie value that maps to a row in a database or Redis) is revocable instantly — you delete the row and the session is dead everywhere. A JWT is stateless and self-verifying, which scales without a database round trip per request, but it can't be revoked before its expiry without maintaining a denylist, which defeats a chunk of the point of using a JWT. For most App Router apps, a short-lived, database-backed session cookie is the boring, correct default; reach for JWTs when you specifically need stateless verification across services that don't share a session store, not because they feel more modern.

Auth.js, Lucia, or rolling your own

Auth.js (formerly NextAuth) is the fastest path if you need OAuth providers — Google, GitHub, and similar — since it handles the OAuth dance, state/PKCE, and provider quirks for you. Lucia was a lighter-weight session library that intentionally left more to you; as of its move to a "copy the code into your project" model, it's less a dependency than a reference implementation to adapt. Rolling your own is entirely reasonable for a single email/password flow with no third-party providers — it's a handful of Server Actions, a sessions table, and a password hash — and it avoids being versioned against a library's breaking changes. The deciding factor is provider count: one or two OAuth providers, use a library; email/password only, rolling your own cookie session is genuinely less code than integrating one.

Checking auth inside Server Components and Server Actions

Middleware protects routes; it doesn't protect the Server Actions or data fetches inside them. Every Server Action that mutates data needs its own session check at the top, because Server Actions are exposed as public HTTP endpoints — anyone can call one directly with curl, bypassing your UI and any middleware matcher that only covers page routes. The safe pattern is a small getSession() helper called at the start of every action and every server-rendered data fetch that returns user-scoped data, not just relying on the page having redirected an unauthenticated user away in the browser.

Middleware matchers don't cover Server Actions by default

A Server Action is invoked via its own POST endpoint, not necessarily the page path your middleware matcher lists. Don't assume protecting /dashboard/:path* in middleware also protects every action a component under /dashboard exposes — verify the session inside the action itself.

Wrapping up

Authentication in Next.js 15 comes down to layering the checks correctly: middleware for a cheap redirect on missing cookies, a Server Action for login that sets an httpOnly cookie, and an explicit session check inside every Server Action and server-rendered data fetch that touches user data — because none of those are protected just because a page redirected in the browser. Reach for Auth.js when you need OAuth providers, and don't be afraid of a plain cookie session when you don't.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.