Auth & Data
Survive domain-locked production Clerk keys in local dev: why auth() throws, the middleware bypass, and a getUserId helper that returns null instead of 500.
1 file
Description
Survive domain-locked production Clerk keys in local dev: why auth() throws, the middleware bypass, and a getUserId helper that returns null instead of 500.
Local dev 500s on a route that reads auth(), the app only has production Clerk keys (domain-locked to the live host), or you need API routes to behave for signed-out callers instead of crashing.
Production Clerk keys are locked to the production domain. On localhost they cannot initialize, so if middleware.ts runs clerkMiddleware() in dev, requests fail. The common workaround is to skip clerkMiddleware() in development. But then auth() in a route handler runs without the middleware context and THROWS rather than returning a signed-out state.
Never call auth() directly in a route handler. Wrap it so a throw becomes "signed out":
import { auth } from "@clerk/nextjs/server";
export async function getUserId(): Promise<string | null> {
try {
const { userId } = await auth();
return userId ?? null;
} catch {
return null; // dev middleware bypass makes auth() throw; treat as signed out
}
}
Every API route calls getUserId() and returns its own 401 when it gets null. This works in both dev (throw caught) and prod (real null).
auth.protect() in middleware redirects browser requests but returns a bare 404 for API paths, which is confusing to integrators. For APIs, do not rely on protect(): read the user id in the handler and return an explicit 401 with a clear body.
auth() directly in a route handler works in production and 500s in dev under the bypass. Always go through the guarded helper..env.local and the host env separately.useAuth() / useUser() belong in client components; auth() and the helper belong on the server. Mixing them throws.Related
Added 2026-07-01. Back to the Skill Library.

New tutorials, open-source projects, and deep dives on coding agents - delivered weekly.