Fix: Clerk Not Working — Auth Not Loading, Middleware Blocking, or User Data Missing
Part of: React & Frontend Errors
Quick Answer
How to fix Clerk authentication issues — ClerkProvider setup, middleware configuration, useUser and useAuth hooks, server-side auth, webhook handling, and organization features.
The Problem
Clerk components render but show a loading state forever:
import { SignInButton, UserButton } from '@clerk/nextjs';
function Header() {
return (
<div>
<UserButton /> {/* Spinner that never resolves */}
<SignInButton />
</div>
);
}Or middleware blocks every page, including the login page:
Redirect loop: /sign-in → middleware redirects to /sign-in → ...Or currentUser() returns null in a Server Component:
import { currentUser } from '@clerk/nextjs/server';
const user = await currentUser();
// null — even though the user is logged inWhy This Happens
Clerk is a managed authentication service. Your app communicates with Clerk’s servers for all auth operations:
ClerkProvidermust wrap the app — all Clerk hooks and components need the provider context. Without it, components render loading states indefinitely because they can’t access the Clerk instance.- Environment variables must be set —
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYandCLERK_SECRET_KEYare required. The publishable key (client-side) initializes the Clerk frontend. The secret key (server-side) authenticates API calls. - Middleware must exclude public routes — Clerk’s
clerkMiddleware()protects routes by default. If the sign-in page itself is protected, you get a redirect loop. Public routes must be explicitly defined. - Server-side auth uses the secret key —
currentUser()andauth()in Server Components communicate with Clerk’s API usingCLERK_SECRET_KEY. If the key is missing or invalid, server-side auth fails silently.
The single highest-impact failure mode for any managed auth provider is “everybody is locked out of everything.” When Clerk fails, the blast radius is the entire authenticated surface of your application. Users can’t log in, can’t reset passwords, can’t access their accounts. Marketing pages may still work, but any product surface behind auth is effectively offline. That is a tier-1 incident at almost every company.
Clerk’s middleware pattern is where most outages originate. clerkMiddleware() runs on every request matching the config.matcher glob. A small typo in the matcher — for example forgetting to exclude _next/static — causes every static asset request to trigger a Clerk session check, which can rate-limit your project against Clerk’s API and cascade into 503s. Worse, if your public route matcher omits /sign-in(.*), the sign-in page itself becomes protected and every unauthenticated user lands in an infinite redirect loop. Both failures present identically to the user: the app is broken.
Treat your auth provider integration like a load-bearing dependency. Add synthetic tests that exercise the full sign-in flow once per minute, alert when sign-in latency spikes or success rate drops below 99%, and version-pin the Clerk SDK so a breaking change in a minor release can’t ship on autopilot. Practice your runbook for a Clerk outage: how do you switch to read-only mode, who has access to the Clerk dashboard to roll back keys, and what is your communication template for users who can’t log in.
Fix 1: Setup with Next.js App Router
npm install @clerk/nextjs# .env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxxxxx
CLERK_SECRET_KEY=sk_test_xxxxxxxx
# Custom routes (optional)
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up// app/layout.tsx — wrap with ClerkProvider
import { ClerkProvider } from '@clerk/nextjs';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}// middleware.ts — protect routes
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
// Define public routes that don't require authentication
const isPublicRoute = createRouteMatcher([
'/',
'/sign-in(.*)',
'/sign-up(.*)',
'/api/webhooks(.*)',
'/blog(.*)',
'/pricing',
]);
export default clerkMiddleware(async (auth, request) => {
if (!isPublicRoute(request)) {
await auth.protect(); // Redirect to sign-in if not authenticated
}
});
export const config = {
matcher: [
// Skip static files and internals
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
'/(api|trpc)(.*)',
],
};Fix 2: Auth in Components
// Client component — hooks
'use client';
import { useUser, useAuth, useClerk, SignInButton, SignOutButton, UserButton } from '@clerk/nextjs';
function Header() {
const { isLoaded, isSignedIn, user } = useUser();
const { userId, sessionId, getToken } = useAuth();
if (!isLoaded) return <div>Loading...</div>;
if (!isSignedIn) {
return (
<div>
<SignInButton mode="modal">
<button>Sign In</button>
</SignInButton>
</div>
);
}
return (
<div>
<span>Welcome, {user.firstName}</span>
<UserButton afterSignOutUrl="/" />
</div>
);
}
// Get JWT for API calls
function ApiCaller() {
const { getToken } = useAuth();
async function callApi() {
const token = await getToken();
const res = await fetch('/api/protected', {
headers: { Authorization: `Bearer ${token}` },
});
return res.json();
}
return <button onClick={callApi}>Call API</button>;
}// Server Component — direct auth access
// app/dashboard/page.tsx
import { currentUser, auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default async function DashboardPage() {
const user = await currentUser();
if (!user) redirect('/sign-in');
return (
<div>
<h1>Dashboard</h1>
<p>Hello, {user.firstName} {user.lastName}</p>
<p>Email: {user.emailAddresses[0]?.emailAddress}</p>
<img src={user.imageUrl} alt={user.firstName ?? ''} />
</div>
);
}
// API Route — check auth
// app/api/protected/route.ts
import { auth } from '@clerk/nextjs/server';
export async function GET() {
const { userId } = await auth();
if (!userId) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
// Fetch data for this user
const data = await db.query.posts.findMany({
where: eq(posts.authorId, userId),
});
return Response.json({ data });
}Fix 3: Custom Sign-In and Sign-Up Pages
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs';
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignIn
appearance={{
elements: {
rootBox: 'mx-auto',
card: 'shadow-lg',
},
}}
routing="path"
path="/sign-in"
signUpUrl="/sign-up"
/>
</div>
);
}
// app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from '@clerk/nextjs';
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignUp
routing="path"
path="/sign-up"
signInUrl="/sign-in"
/>
</div>
);
}Fix 4: Webhook Handling (Sync Users to Database)
// app/api/webhooks/clerk/route.ts
import { Webhook } from 'svix';
import { headers } from 'next/headers';
import type { WebhookEvent } from '@clerk/nextjs/server';
export async function POST(req: Request) {
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET!;
const headerPayload = await headers();
const svix_id = headerPayload.get('svix-id');
const svix_timestamp = headerPayload.get('svix-timestamp');
const svix_signature = headerPayload.get('svix-signature');
if (!svix_id || !svix_timestamp || !svix_signature) {
return new Response('Missing svix headers', { status: 400 });
}
const body = await req.text();
const wh = new Webhook(WEBHOOK_SECRET);
let event: WebhookEvent;
try {
event = wh.verify(body, {
'svix-id': svix_id,
'svix-timestamp': svix_timestamp,
'svix-signature': svix_signature,
}) as WebhookEvent;
} catch {
return new Response('Invalid signature', { status: 400 });
}
switch (event.type) {
case 'user.created': {
const { id, email_addresses, first_name, last_name, image_url } = event.data;
await db.insert(users).values({
clerkId: id,
email: email_addresses[0]?.email_address ?? '',
name: `${first_name ?? ''} ${last_name ?? ''}`.trim(),
avatar: image_url,
});
break;
}
case 'user.updated': {
const { id, email_addresses, first_name, last_name, image_url } = event.data;
await db.update(users).set({
email: email_addresses[0]?.email_address ?? '',
name: `${first_name ?? ''} ${last_name ?? ''}`.trim(),
avatar: image_url,
}).where(eq(users.clerkId, id));
break;
}
case 'user.deleted': {
if (event.data.id) {
await db.delete(users).where(eq(users.clerkId, event.data.id));
}
break;
}
}
return new Response('OK', { status: 200 });
}Fix 5: Custom Theme and Appearance
// app/layout.tsx — global appearance customization
import { ClerkProvider } from '@clerk/nextjs';
import { dark } from '@clerk/themes';
<ClerkProvider
appearance={{
baseTheme: dark, // Or import { shadesOfPurple } from '@clerk/themes'
variables: {
colorPrimary: '#3b82f6',
colorBackground: '#1a1a2e',
colorText: '#ffffff',
borderRadius: '8px',
},
elements: {
card: 'bg-gray-900 shadow-xl',
headerTitle: 'text-white',
socialButtonsBlockButton: 'bg-gray-800 border-gray-700 hover:bg-gray-700',
formFieldInput: 'bg-gray-800 border-gray-600',
footerActionLink: 'text-blue-400 hover:text-blue-300',
},
}}
>Production Incident: Auth Provider Fails, Users Locked Out of Entire App
The classic Clerk outage runbook: a deploy goes out at 4 PM, ten minutes later support tickets start flooding in — “I can’t log in,” “the dashboard is blank,” “I’m stuck on a loading spinner.” Sentry shows no errors because the failure is on the auth boundary, not in your application code. Your synthetic monitoring (if you have it) starts failing the login flow check.
The first thing to check is whether the issue is on Clerk’s side or yours. Open the Clerk dashboard and look at the API request log for your project. If requests are arriving and returning 200, the SDK on your side is broken. If requests are missing entirely, your environment variables are wrong or the SDK is failing to initialize. If requests are arriving and returning 4xx, you have a key mismatch or a permission issue.
Common production failure modes:
Rotated keys not propagated. A security review rotates CLERK_SECRET_KEY. The new key is set in production env but a subset of edge workers cache the old key. Some requests succeed, others return 401. The intermittent pattern is a strong signal.
Test keys deployed to production. A developer copies a .env.local into production env and ships pk_test_* and sk_test_* keys. The test instance has no users. Every login attempt creates a new test user that the production database doesn’t know about.
Middleware matcher catches Next.js internals. A regex like '/((?!api).*)' accidentally protects /_next/static/chunks/*.js. Static assets return 307 redirects to /sign-in, the browser follows them, the React app fails to bootstrap, and the page is blank.
The fastest mitigation when the cause is unclear: pin a known-good deployment in Vercel or Cloudflare Pages while you debug. Do not try to fix Clerk under time pressure on main — each deploy is several minutes, and login failures compound user frustration with every retry.
For Clerk service outages (their side), monitor status.clerk.com and consider a degraded-mode banner that tells users sign-in is unavailable rather than letting them spin on a loading state. A comparable pattern applies to any managed auth provider — including self-hosted libraries — where the auth boundary is the single point of failure for every authenticated surface.
Fix 6: Organizations (Multi-Tenant)
// Enable organizations in Clerk Dashboard → Organizations → Enable
// Client — organization switcher
'use client';
import { OrganizationSwitcher, useOrganization } from '@clerk/nextjs';
function OrgHeader() {
const { organization, isLoaded } = useOrganization();
return (
<div>
<OrganizationSwitcher
hidePersonal={false}
afterCreateOrganizationUrl="/dashboard"
afterSelectOrganizationUrl="/dashboard"
/>
{organization && <p>Current org: {organization.name}</p>}
</div>
);
}
// Server — check organization membership
import { auth } from '@clerk/nextjs/server';
export async function GET() {
const { userId, orgId, orgRole } = await auth();
if (!orgId) {
return Response.json({ error: 'No organization selected' }, { status: 400 });
}
// orgRole: 'org:admin' | 'org:member' | custom roles
if (orgRole !== 'org:admin') {
return Response.json({ error: 'Admin access required' }, { status: 403 });
}
// Fetch org-specific data
const data = await db.query.projects.findMany({
where: eq(projects.organizationId, orgId),
});
return Response.json({ data });
}Still Not Working?
Components show infinite loading spinner — NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY is missing or wrong. Check .env.local — the variable must start with pk_test_ (development) or pk_live_ (production). Also verify ClerkProvider wraps your entire app in layout.tsx.
Middleware causes redirect loop — the sign-in page is being protected by middleware. Add /sign-in(.*) and /sign-up(.*) to your public routes matcher. Also add any webhook endpoints: /api/webhooks(.*).
currentUser() returns null in Server Components — CLERK_SECRET_KEY is missing or invalid. This key (starting with sk_test_ or sk_live_) is needed for server-side Clerk API calls. Also ensure the middleware is running — it attaches auth data to the request.
Webhooks not firing — set up the webhook endpoint in Clerk Dashboard → Webhooks. The URL must be publicly reachable (not localhost). For local development, use ngrok to expose your local server. Install the svix package for signature verification.
Cross-origin auth fails between subdomains — if your app spans app.example.com and api.example.com, Clerk’s session cookies need a parent-domain config. Set CLERK_COOKIE_DOMAIN=.example.com and ensure both subdomains share the publishable key. Without it, the session cookie is host-only and the API rejects every request as unauthenticated.
Session keeps expiring after a few minutes — Clerk’s default session lifetime is configurable in the dashboard, but the SDK also enforces a token refresh window. If your reverse proxy strips the Set-Cookie header on response, the refresh fails silently and the next page load logs the user out. Verify cookies are present in browser DevTools → Application → Cookies after a successful login.
Webhook signature verification fails intermittently — the raw request body must be passed to svix.verify() exactly as received. If any middleware reads and re-parses the body (common with body-parsing middleware that runs before your route), the signature no longer matches the bytes. In Next.js App Router, use req.text() once and pass that string to verification. See Fix: Stripe Webhook Signature Verification Failed for the same pattern with Stripe.
For related auth issues, see Fix: Auth.js Not Working, Fix: Better Auth Not Working, and Fix: jose JWT Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Better Auth Not Working — Login Failing, Session Null, or OAuth Callback Error
How to fix Better Auth issues — server and client setup, email/password and OAuth providers, session management, middleware protection, database adapters, and plugin configuration.
Fix: next-safe-action Not Working — Action Not Executing, Validation Errors Missing, or Type Errors
How to fix next-safe-action issues — action client setup, Zod schema validation, useAction and useOptimisticAction hooks, middleware, error handling, and authorization patterns.
Fix: nuqs Not Working — URL State Not Syncing, Type Errors, or Server Component Issues
How to fix nuqs URL search params state management — useQueryState and useQueryStates setup, parsers, server-side access, shallow routing, history mode, and Next.js App Router integration.
Fix: Vercel AI SDK Not Working — Streaming Not Rendering, useChat Stuck Loading, or Provider Errors
How to fix Vercel AI SDK issues — useChat and useCompletion hooks, streaming responses with streamText, provider configuration for OpenAI and Anthropic, tool calling, and Next.js integration.