Back to Knowledge
Updated Sep 2026

Protected Routes

Pages and endpoints that require a signed-in user. Think bouncer at a club: no wristband, no entry. The twist in 2026: the bouncer at the front door isn't enough. You need one at every door that matters.

1Why Route Protection Matters

Without protection, anyone who guesses a URL sees private content. /dashboard should show your data, not everyone's. And because your API routes and Server Actions

Server Actions

Next.js feature that lets you run server-side code directly from React components. Mark a function with 'use server' and call it from forms or buttons.

"Like a direct line to the kitchen from your table. No waiter needed — press a button and the order goes straight to the chef."

are reachable with a plain HTTP request, hiding a button protects nothing.

Without protection

  • • Anyone can open /dashboard
  • • User A can load user B's records
  • • APIs and actions answer anyone

With protection

  • • Guests get redirected to sign in
  • • Every query is scoped to the user
  • • APIs return 401 / 403

Examples use Auth.js v5's auth() from @/auth (setup in the Authentication playbook). Clerk and Better Auth follow the same pattern with their own session helpers.

2The Mental Model: Three Doors

Picture a bank. The doorman waves off obvious trouble. The teller checks your ID before talking about your account. The vault checks again before opening your box. Skipping the doorman is inconvenient. Skipping the vault check is a headline.

proxy.ts

The doorman

Fast redirect if there's obviously no session. Optimistic: reads a cookie, doesn't guarantee anything.

Nice to have

Page / layout

The host at the table

Server Component calls auth() and redirects before rendering. No flash of private content.

Required for private pages

Data layer

The vault

Every function that reads or writes user data verifies the session and ownership itself.

Non-negotiable

The rule: proxy is an optimistic check. Always re-check auth in server components, route handlers, and Server Actions. The Next.js docs say it plainly: verify authentication and authorization inside each Server Function rather than relying on Proxy alone.

3Door 1: proxy.ts (Optimistic Redirects)

In Next.js 16, middleware

Middleware

Code that runs BETWEEN receiving a request and sending a response, used for logging, auth checks, redirects, and validation. Express apps chain middleware functions. In Next.js 16 the old middleware.ts file is now called proxy.ts, with an exported `proxy` function.

"Like airport security. Every passenger (request) passes through before reaching the gate."

is called proxy.ts

proxy.ts

The Next.js 16 name for what used to be middleware.ts: a file at the project root whose `export function proxy(request)` runs before a request reaches your pages, for redirects, rewrites, and auth checks. Upgrade with `npx @next/codemod@canary middleware-to-proxy .`; with Auth.js v5 it can be as short as `export { auth as proxy } from "@/auth"`.

"Like the host stand at a restaurant entrance. Everyone passes it first, and it decides where you're seated or if you get in at all."

. It lives in the project root and runs before matching requests. The simplest Auth.js version is one line:

proxy.ts

export { auth as proxy } from "@/auth";

Want to redirect guests away from specific sections? Wrap your logic in auth():

proxy.ts

import { auth } from "@/auth";
import { NextResponse } from "next/server";

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isPrivate =
    req.nextUrl.pathname.startsWith("/dashboard") ||
    req.nextUrl.pathname.startsWith("/account");

  if (isPrivate && !isLoggedIn) {
    return NextResponse.redirect(new URL("/api/auth/signin", req.nextUrl.origin));
  }
});

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

The matcher

Proxy only runs on matching paths. :path* means "and everything under it."

Why it's not enough

Rename a folder, tweak the matcher, or move a Server Action to another route, and coverage silently disappears. Server Actions are POSTs to the page they're used on, so an excluded path means excluded actions.

Terminal (upgrading from middleware.ts)

npx @next/codemod@canary middleware-to-proxy .

4Door 2: Check in the Page (Server Component)

A Server Component

Server Component

A React component that runs ONLY on the server. Can directly access databases, fetch data, and keeps secrets safe. The default in Next.js App Router.

"Like the kitchen in a restaurant. Customers never see it, but that's where the magic happens."

can check the session before a single byte of HTML leaves the server. No flash of private content, no JavaScript required.

app/dashboard/page.tsx

import { auth } from "@/auth";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  const session = await auth();
  if (!session?.user) redirect("/api/auth/signin");

  return <h1>Welcome, {session.user.name}</h1>;
}

Layouts are not a security boundary

A check in layout.tsx is great UX, but layouts don't re-run on every navigation and don't guard the data your pages fetch. Keep the real check next to the data (Door 3).

5Door 3: A Data Access Layer

The sturdiest pattern: put every user-data query behind a small set of server-only functions that check the session themselves. Then it doesn't matter which page, action, or API calls them. The check comes along for free.

lib/dal.ts

import "server-only";
import { cache } from "react";
import { redirect } from "next/navigation";
import { and, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { invoices } from "@/lib/schema";

// One session lookup per request, reused everywhere
export const requireUser = cache(async () => {
  const session = await auth();
  if (!session?.user?.id) redirect("/api/auth/signin");
  return session.user;
});

export async function getInvoice(invoiceId: string) {
  const user = await requireUser();
  // Ownership is part of the query, not an afterthought
  const [invoice] = await db
    .select()
    .from(invoices)
    .where(and(eq(invoices.id, invoiceId), eq(invoices.userId, user.id)));
  return invoice ?? null;
}
  • import "server-only" makes the build fail if a Client Component ever imports this file (npm install server-only).
  • session.user.id is there when you use a database adapter; with JWT-only sessions, add it in the session callback.
  • Tell Claude in your CLAUDE.md

    CLAUDE.md

    A Markdown file Claude Code reads at the start of every session: project context, commands, conventions, and rules. Put it at `./CLAUDE.md` (shared with the team), `~/.claude/CLAUDE.md` (personal, all projects), or `CLAUDE.local.md` (personal, gitignored). Run `/init` to generate a starter; AGENTS.md is read too.

    "Like a welcome packet for a new team member. It tells Claude everything it needs to know about your project."

    : "All user-data reads and writes go through lib/dal.ts." It will follow the pattern.

6Route Handlers & Server Actions

API routes return 401, not a redirect

app/api/user/profile/route.ts

import { auth } from "@/auth";
import { NextResponse } from "next/server";

export async function GET() {
  const session = await auth();
  if (!session?.user) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  const profile = await getProfile(session.user.id);
  return NextResponse.json(profile);
}

Server Actions check inside the action

app/invoices/actions.ts

"use server";

import { and, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { invoices } from "@/lib/schema";

export async function deleteInvoice(invoiceId: string) {
  const session = await auth();
  if (!session?.user) throw new Error("Unauthorized");

  // Scope the write to the owner, so guessing an ID does nothing
  await db
    .delete(invoices)
    .where(and(eq(invoices.id, invoiceId), eq(invoices.userId, session.user.id)));
}

A Server Action is a public POST endpoint with a nice function signature. Treat its arguments like any request body: untrusted until validated.

7Role-Based Access (Admin Pages)

Signed in isn't the same as allowed. Admin pages need authorization

Authorization

Verifying WHAT you can do. After you're authenticated, authorization checks if you have permission for a specific action.

"Like having a building pass but only for certain floors. You're in, but not everywhere."

: a role or allow-list check, on the page and on every admin action and API.

app/admin/page.tsx

import { auth } from "@/auth";
import { notFound } from "next/navigation";
import { isAdminUser } from "@/lib/config/admin";

export default async function AdminPage() {
  const session = await auth();
  // Authentication AND authorization
  if (!session?.user?.email || !isAdminUser(session.user.email)) {
    notFound(); // don't even confirm the page exists
  }
  return <AdminDashboard />;
}

Saucy pattern

Keep one isAdminUser() helper (e.g. in lib/config/admin.ts) and call it everywhere. One definition, no drift. Admin API routes return 403.

8Client-Side Checks (UX Only)

In Client Components

Client Component

A React component that runs in the browser. Required for interactivity (useState, onClick, useEffect). Add "use client" at the top of the file.

"Like the dining room. The customer sees it, interacts with it, and clicks the buttons."

, useSession (inside a SessionProvider) is handy for showing an avatar or hiding a button. It is not security: anything in the browser can be changed by the person using it.

components/UserMenu.tsx

"use client";

import { useSession } from "next-auth/react";

export function UserMenu() {
  const { data: session, status } = useSession();
  if (status === "loading") return null;
  if (!session) return <a href="/api/auth/signin">Sign in</a>;
  return <span>Hi, {session.user?.name}</span>;
}

9Common Traps

Relying on proxy.ts alone

A matcher change or refactor removes coverage without any error, and you find out from a user (or an attacker).

Fix: Keep proxy for redirects. Put the real check in pages, route handlers, Server Actions, and your data layer.

Only protecting the UI

Hiding the Delete button doesn't stop someone calling the action or API directly.

Fix: UI hiding is UX. Security is a server-side check on every request.

Checking login but not ownership

Signed-in user A requests /invoices/124, which belongs to user B, and gets it.

Fix: Filter by owner in the query itself (WHERE user_id = session user), or compare and return 404.

Reading params synchronously

Next.js 16 made params a Promise. params.id !== session.user.id compares a Promise, which never matches.

Fix: const { id } = await params; first, then compare.

Still shipping middleware.ts

On Next.js 16 the file must be proxy.ts, exporting proxy (or a default export).

Fix: Run npx @next/codemod@canary middleware-to-proxy .

Exposing admin routes

/admin hidden from the nav is still reachable by typing it.

Fix: Role check on the page and on every admin action/API; 404 or 403 for everyone else.

Doors locked?

Make sure login itself survives production, then run the pre-launch checklist.