Where Auth Belongs

Where Authentication Belongs in the App Router

There are five places a check can go, and they are not interchangeable.

The five entry points of an App Router request
Place Sees Good for Cannot be trusted to
proxy.ts the cookie redirecting anonymous visitors authorize anything
layout.js request APIs shell UI, navigation guard nested segments
page.js request APIs choosing what to render guard actions on that page
Server Action request APIs mutations be skipped, ever
Route handler request APIs API responses inherit a page's check

The proxy is first and cheapest, so a redirect belongs there. It runs on every matched request, prefetches included, so it must read the cookie and nothing else — a database round trip there is paid on every hover. That is an optimistic check: a signed cookie decides where to send a browser, not what data to hand out.

A proxy that redirects anonymous visitors (proxy.ts)TypeScript
export default async function proxy(request: NextRequest) {
  const session = await getIronSession<SessionData>(await cookies(), sessionOptions);
  if (request.nextUrl.pathname.startsWith("/dashboard") && !session.userId) {
    return NextResponse.redirect(new URL("/login", request.nextUrl));
  }
  return NextResponse.next();
}
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
Output
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" localhost:3319/dashboard
307 -> http://localhost:3319/login

Now the limits. A matcher is a path filter, and Server Functions are not paths: excluding /admin from the matcher also removes proxy coverage from every action defined there.

Layouts are the second trap. A layout runs when a user first lands inside its segment and then does not re-run on client-side navigations within it, because the router re-renders only the segments that changed: a session that expires mid-visit is never re-checked. Worse, the router renders the page and any parallel slots itself, so a layout that returns null for an unauthorized user stops nothing — the page ran anyway, and its output is in the RSC payload.

That leaves pages, Server Actions and route handlers. Put the check in all three, behind one function.