Skip to main content
Watch: Claude Code Just Killed After Effects

Auth & Data

Clerk Dev vs Prod Gotchas

Use when a Next.js app uses domain-locked production Clerk keys, so local development cannot run clerkMiddleware and auth() throws in route handlers. Also use when a dashboard route 500s locally but works deployed, or an API returns 404 where it should return 401. Covers the middleware bypass and a null-safe server helper. If auth works in production but not locally, use this skill.

clerkauthnextjsmiddlewaredev-env

1 file

Description

Use when a Next.js app uses domain-locked production Clerk keys, so local development cannot run clerkMiddleware and auth() throws in route handlers. Also use when a dashboard route 500s locally but works deployed, or an API returns 404 where it should return 401. Covers the middleware bypass and a null-safe server helper. If auth works in production but not locally, use this skill.

Install
SKILL.md

name: clerk-dev-vs-prod-gotchas description: Use when a Next.js app uses production Clerk keys that are domain-locked, so local development cannot run clerkMiddleware and auth() throws in route handlers. Covers the middleware bypass, a null-safe server helper, and the API 401 vs 404 difference.

Clerk Dev vs Prod Gotchas

When to trigger

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.

Why it happens

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.

The fix: a null-safe server read

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).

API routes return 404, not 401, from middleware protect

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.

Pitfalls

  • Calling auth() directly in a route handler works in production and 500s in dev under the bypass. Always go through the guarded helper.
  • Do not commit dev-only publishable keys expecting them to reach production; the domain lock is the whole point. Keep keys in .env.local and the host env separately.
  • useAuth() / useUser() belong in client components; auth() and the helper belong on the server. Mixing them throws.
AI Development Stack

Get Smarter About AI Dev

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

One email per weekReal code, not theoryFree forever