Clerk: redirect loop after authentication [2026 fix]
Redirect loop caused by misconfigured afterSignInUrl or missing middleware—fix by setting absolute URLs and ensuring middleware runs before redirects.
Clerk: Redirect Loop After Authentication [2am Fix]
TL;DR
Cause:afterSignInUrl is relative or middleware isn't intercepting auth state before your app redirects.
Fix: Use absolute URLs in Clerk config and ensure clerkMiddleware() runs before all route handlers.---
Real Console Error Messages
Look for these exact patterns in your browser/server logs:
``` ERROR: Redirect loop detected: http://localhost:3000/auth/callback redirecting to http://localhost:3000/auth/callback ```
``` WARNING: ClerkProvider: User authenticated but sessionId is stale. Forcing re-authentication. ```
``` ERROR [Clerk]: afterSignInUrl "/dashboard" is not an absolute URL. Expected format: "https://example.com/dashboard" ```
``` TypeError: Cannot read property 'isLoaded' of undefined at useAuth hook ```
``` POST /api/auth/callback 308 (redirect loop) - middleware did not process request ```
---
The Problem: Code Comparison
❌ BROKEN CODE
middleware.ts (missing or misconfigured): ```typescript // This doesn't exist or runs too late export default function middleware(request: NextRequest) { return NextResponse.next(); }
export const config = { matcher: ["/dashboard/:path*"], // Too narrow! }; ```
app.tsx (relative afterSignInUrl): ```typescript <ClerkProvider publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY} afterSignInUrl="/dashboard" // ❌ RELATIVE URL > <App /> </ClerkProvider> ```
route.ts (auth callback): ```typescript export async function GET(req: NextRequest) { const { isLoaded, userId } = useAuth(); // ❌ Hook in server component if (!userId) return NextResponse.redirect("/"); return NextResponse.json({ success: true }); } ```
✅ FIXED CODE
middleware.ts (correct): ```typescript import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server';
const isProtectedRoute = createRouteMatcher([ '/dashboard(.*)', '/api/protected(.*)', ]);
export default clerkMiddleware((auth, req) => { if (isProtectedRoute(req)) { auth().protect(); } return NextResponse.next(); });
export const config = { matcher: [ '/((?!.*\\.[\\w]+$|_next).*)', // ✅ Matches ALL routes except static '/api/(.*)', ], }; ```
app.tsx (absolute afterSignInUrl):
```typescript
const clerkPubKey = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY;
const signInUrl = process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL ||
${process.env.NEXT_PUBLIC_APP_URL}/sign-in;
<ClerkProvider
publishableKey={clerkPubKey}
afterSignInUrl={${process.env.NEXT_PUBLIC_APP_URL}/dashboard} // ✅ ABSOLUTE
afterSignOutUrl={${process.env.NEXT_PUBLIC_APP_URL}/}
>
<App />
</ClerkProvider>
```
route.ts (server-side, no hooks): ```typescript import { auth } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) { const { userId } = await auth(); // ✅ Async function, not hook if (!userId) { return NextResponse.redirect( new URL('/sign-in', process.env.NEXT_PUBLIC_APP_URL) ); } return NextResponse.json({ success: true }); } ```
.env.local (required variables): ```bash NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_... CLERK_SECRET_KEY=sk_test_... NEXT_PUBLIC_APP_URL=https://localhost:3000 # or your production domain ```
---
Why This Happens
1. Relative URLs: Clerk can't build absolute redirect URLs without your app's base URL, causing auth callback to loop
2. Missing middleware: Auth state isn't established before your app tries to check it
3. Hook misuse: Using useAuth() in server components breaks the auth flow
4. Narrow matcher: Middleware skips auth callback routes
5. Stale session: User is authenticated but ClerkProvider hasn't synced state
---
Still Broken? Check These Too
1. [CORS/Domain Mismatch](/?guide=clerk-cors-errors): Verify NEXT_PUBLIC_APP_URL matches your exact domain (including protocol). Localhost ≠ 127.0.0.1.
2. [Multiple ClerkProvider Instances](/?guide=clerk-multiple-providers): Ensure only ONE <ClerkProvider> wraps your app. Check _app.tsx, layout.tsx, and any custom auth wrappers.
3. [Session Token Expired](/?guide=clerk-session-refresh): Add <ClerkLoaded> wrapper around protected components. Session might be valid but provider hasn't hydrated yet.
---
Version Note
I'm confident about Clerk SDK v5+ behavior here. If you're on v4 or earlier, clerkMiddleware() signature differs—check official docs below. Comment if you hit version-specific issues!
---
Resources
---
Found a different variation? Drop it in the comments—2am bugs are always weird, and your fix might save someone else's night.