Skip to content

Fix: Clerk Not Working — Auth Not Loading, Middleware Blocking, or User Data Missing

FixDevs · (Updated: )

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 in

Why This Happens

Clerk is a managed authentication service. Your app communicates with Clerk’s servers for all auth operations:

  • ClerkProvider must 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 setNEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY are 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 keycurrentUser() and auth() in Server Components communicate with Clerk’s API using CLERK_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 spinnerNEXT_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 ComponentsCLERK_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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles