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. 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."/dashboard should show your data, not everyone's. And because your API routes and Server Actions
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." 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."
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."
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.idis there when you use a database adapter; with JWT-only sessions, add it in thesessioncallback.- Tell Claude in your CLAUDE.md: "All user-data reads and writes go through lib/dal.ts." It will follow the pattern.
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."
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."
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.