A page guard and an API guard look like the same job. They are not, and using
the page pattern in a route handler produces one of the most confusing bugs in a
Next.js app: a fetch that returns 200 OK with a body full of sign-in HTML.
Why the page pattern breaks handlers
// route.ts: wrong
export async function POST() {
const user = await requireUser(); // redirects when signed out
// ...
}
redirect() throws a special error that Next turns into a 307. fetch follows
redirects by default, so the browser dutifully requests /sign-in, gets a 200
with an HTML page, and hands it to your await response.json(), which throws
Unexpected token '<'. Nothing in that error mentions authentication.
Use the right helper
import { AuthError, authErrorResponse, requireApiRole } from "@/lib/auth/session";
export async function POST(request: Request) {
try {
const user = await requireApiRole("admin");
const body = await request.json();
return Response.json({ ok: true, actor: user.id });
} catch (error) {
const denied = authErrorResponse(error);
if (denied) return denied;
throw error;
}
}
- 401: no session. The client should send the user to sign in.
- 403: there is a session, it lacks the role. Signing in again changes nothing; show "you do not have access".
Collapsing both into one status forces every client to guess. A 401 that really meant 403 sends a signed-in user round a sign-in loop they cannot win.
Pages redirect, and 404 for the wrong role
export default async function InvoicesPage() {
const user = await requireRole("admin", "/admin/invoices");
return <Invoices />;
}
Anonymous → redirect to sign-in carrying a return path, so the user lands where
they were going. Signed-in-but-wrong-role → notFound(). A 403 page confirms
that /admin/invoices exists, which is free reconnaissance; a 404 says nothing.
Server actions are handlers wearing a page's clothes
A server action is a POST endpoint with a generated id, callable by anyone who can read your bundle. "It is only used on an admin page" is not a control:
"use server";
export async function deleteUser(id: string) {
await requireRole("admin"); // first line, before any argument is used
// ...
}
Actions can redirect (they run in a navigation context) so requireRole is
appropriate here. What matters is that the check exists and runs first.
The proxy is not the boundary
clerkMiddleware runs before rendering and is the right place to bounce
signed-out visitors so they never see a flash of the shell. It is the wrong
place for the only check:
- It runs on a matcher, and the matcher can be edited by someone chasing an unrelated problem.
- It does not see every path that reaches a server action.
- A route added outside the matcher is silently unprotected, and nothing fails in a way anyone notices.
The rule of thumb: delete proxy.ts mentally and ask whether the endpoint is
still protected. If not, the check is in the wrong place.
auth() does need the proxy to have run, though. A route that throws "auth()
was called but Clerk can't detect usage of clerkMiddleware" is a route the
matcher does not cover: the fix is the matcher, not a try/catch.
Webhooks are the exception, in both directions
/api/webhooks/clerk must be public (Clerk arrives with no session) and
must still authenticate, with a Svix signature over the raw body. Protecting it
with the proxy means no delivery ever arrives. Leaving it unverified means
anyone can write to your users table.
Every public route is public for a reason you can name. Write the reason in a comment above it.
What the client should do with each status
const response = await fetch("/api/admin/refunds", { method: "POST", body });
if (response.status === 401) {
router.push(`/sign-in?next=${encodeURIComponent(pathname)}`);
return;
}
if (response.status === 403) {
setError("You do not have access to this action.");
return;
}
if (!response.ok) {
setError("Something went wrong. Try again.");
return;
}
Three branches, one line each, and every failure mode becomes legible instead of a JSON parse error.
Checking your work
curl -i -X POST http://localhost:3000/api/your-routewith no cookie: 401 and a JSON body. No HTML, no redirect.- The same call with a signed-in but under-privileged cookie: 403.
- A protected page while signed out: 307 to
/sign-in?next=..., and after signing in you land on the original path. - A protected page with the wrong role: 404.
- Every route handler that mutates has a
requireApi*call before it reads the request body.