Summary
When Clerk performs its post-authentication client-side navigation into a route whose Server Component calls redirect(), the Next.js App Router never commits the navigation. The page hangs on the Rendering… indicator, parked on the intermediate route, while the server has already resolved the redirect and returned the destination.
Reproduces on the latest published release. A page reload clears it, so this is stuck client state rather than a server deadlock.
Reproduction
Minimal repro, derived from clerk/clerk-nextjs-app-quickstart with four routes added: https://github.com/manovotny/clerk-nextjs-post-auth-redirect-hang
pnpm install
# add a dev instance's keys to .env.local
pnpm dev
# open /repro
Click "1. Sign in → /setup" and complete sign-in. The button is a <SignInButton mode="modal" forceRedirectUrl="/setup">; /setup is a Server Component that calls await auth() then redirect('/landed').
Expected: land on /landed, which renders LANDED OK.
Actual: hangs on Rendering…, URL stays at /setup.
Environment
| Package |
Version |
@clerk/nextjs |
7.7.4 (also reproduced on 7.7.1 and 7.5.18) |
@clerk/shared |
4.28.1 |
@clerk/react |
6.14.1 |
next |
16.2.10 |
react / react-dom |
19.2.5 |
Isolation
The repro ships controls. Results:
| Trigger |
Destination |
Result |
<Link> click, signed out |
/setup (auth() + redirect) |
lands |
<Link> click, signed out |
/plain (redirect only, no Clerk) |
lands |
<Link> click, signed in |
/setup |
lands |
| Post-auth push |
/setup |
hangs on /setup |
| Post-auth push |
/plain |
hangs on /plain |
The single variable is Clerk's post-auth push. Specifically ruled out:
- Not
auth() on the destination. The Clerk-free /plain control hangs identically.
- Not being signed in. The same route via
<Link> while signed in works.
- Not the redirect itself.
/setup and /plain both return 307 and resolve to /landed 200 on every other path.
- Doesn't look like a Suspense/streaming problem. Adding
app/setup/loading.tsx (the redirecting segment, not a route-group root) didn't clear the hang for us.
Additional observations
- The server completes fully. During a hang the dev server logs the destination returning
200 twice.
- No console errors. Verified with devtools open for the whole flow.
- The UI renders the signed-out state (
<Show when="signed-out"> branch) despite authentication having succeeded — suggesting setActive never finishes its post-navigation steps.
Server log captured during a hang:
POST /repro 200
└─ ƒ invalidateCacheAction() in 9ms
GET /setup 200
GET /landed 200
GET /landed 200
Suspected mechanism — hypothesis, not traced
I haven't instrumented this. Offering it as a starting point, with the reasoning, so it can be confirmed or discarded quickly.
packages/nextjs/src/app-router/client/useInternalNavFun.ts wraps router.push in startTransition() and resolves the queued navigation promises only when isPending flips back to false:
// useInternalNavFun.ts
useEffect(() => {
if (!isPending) {
flushPromises();
}
}, [isPending]);
The file already documents a closely related failure mode (lines 25–36), where useTransition's isPending can get permanently stuck at true when Next.js deduplicates or cancels a redundant router action — currently guarded only for the duplicate-destination case:
// Calling startTransition with a duplicate router.push()
// can permanently stick useTransition's isPending at `true` when Next.js
// deduplicates/cancels the redundant router action.
Hypothesis: pushing to a route that server-redirects is another way to reach that stuck state. The pushed destination (/setup) never becomes the committed route, since the router ends up resolving /landed instead, so isPending never settles the way flushPromises() expects. The navigation promise never resolves, setActive never completes, and window.__internal_onAfterSetActive → router.refresh() (set in app-router/client/ClerkProvider.tsx) never fires.
This would account for every observation: the park on the intermediate route, the successful server fetches, the signed-out UI, the silence in the console, loading.tsx having no effect, and <Link> clicks working (they never enter useInternalNavFun).
Cheap way to confirm or refute
While the page is hung, in the console:
window.__clerk_internal_navigations.push
If promisesBuffer.length > 0 and pendingDestination === '/setup', the navigation promise never flushed and the hypothesis holds. If the buffer is empty, the stall is downstream and this is the wrong tree.
Relevant files
packages/nextjs/src/app-router/client/useInternalNavFun.ts — transition wrapper and promise buffer
packages/nextjs/src/app-router/client/useAwaitablePush.ts / useAwaitableReplace.ts
packages/nextjs/src/app-router/client/ClerkProvider.tsx — __internal_onBeforeSetActive / __internal_onAfterSetActive
packages/nextjs/src/app-router/server-actions.ts — invalidateCacheAction
Impact
This isn't an exotic configuration. clerk/clerk-nextjs-app-quickstart ships app/protected/page.tsx doing await auth() then redirect() with no loading.tsx, so the pattern is in the template we hand new users. Any post-auth landing route that redirects — onboarding gates, org selection, role-based routing — is exposed.
Source of the report
Originally surfaced by an external contributor in clerk/clerk-docs#3525, which proposed documenting a loading.tsx workaround. The symptom report was accurate; the proposed mechanism (ClerkProvider state clashing with a blocking transition) looks wrong, and the loading.tsx fix didn't resolve the hang in our repro.
Summary
When Clerk performs its post-authentication client-side navigation into a route whose Server Component calls
redirect(), the Next.js App Router never commits the navigation. The page hangs on theRendering…indicator, parked on the intermediate route, while the server has already resolved the redirect and returned the destination.Reproduces on the latest published release. A page reload clears it, so this is stuck client state rather than a server deadlock.
Reproduction
Minimal repro, derived from
clerk/clerk-nextjs-app-quickstartwith four routes added: https://github.com/manovotny/clerk-nextjs-post-auth-redirect-hangClick "1. Sign in → /setup" and complete sign-in. The button is a
<SignInButton mode="modal" forceRedirectUrl="/setup">;/setupis a Server Component that callsawait auth()thenredirect('/landed').Expected: land on
/landed, which rendersLANDED OK.Actual: hangs on
Rendering…, URL stays at/setup.Environment
@clerk/nextjs7.7.4(also reproduced on7.7.1and7.5.18)@clerk/shared4.28.1@clerk/react6.14.1next16.2.10react/react-dom19.2.5Isolation
The repro ships controls. Results:
<Link>click, signed out/setup(auth()+redirect)<Link>click, signed out/plain(redirectonly, no Clerk)<Link>click, signed in/setup/setup/setup/plain/plainThe single variable is Clerk's post-auth push. Specifically ruled out:
auth()on the destination. The Clerk-free/plaincontrol hangs identically.<Link>while signed in works./setupand/plainboth return307and resolve to/landed200on every other path.app/setup/loading.tsx(the redirecting segment, not a route-group root) didn't clear the hang for us.Additional observations
200twice.<Show when="signed-out">branch) despite authentication having succeeded — suggestingsetActivenever finishes its post-navigation steps.Server log captured during a hang:
Suspected mechanism — hypothesis, not traced
I haven't instrumented this. Offering it as a starting point, with the reasoning, so it can be confirmed or discarded quickly.
packages/nextjs/src/app-router/client/useInternalNavFun.tswrapsrouter.pushinstartTransition()and resolves the queued navigation promises only whenisPendingflips back tofalse:The file already documents a closely related failure mode (lines 25–36), where
useTransition'sisPendingcan get permanently stuck attruewhen Next.js deduplicates or cancels a redundant router action — currently guarded only for the duplicate-destination case:Hypothesis: pushing to a route that server-redirects is another way to reach that stuck state. The pushed destination (
/setup) never becomes the committed route, since the router ends up resolving/landedinstead, soisPendingnever settles the wayflushPromises()expects. The navigation promise never resolves,setActivenever completes, andwindow.__internal_onAfterSetActive→router.refresh()(set inapp-router/client/ClerkProvider.tsx) never fires.This would account for every observation: the park on the intermediate route, the successful server fetches, the signed-out UI, the silence in the console,
loading.tsxhaving no effect, and<Link>clicks working (they never enteruseInternalNavFun).Cheap way to confirm or refute
While the page is hung, in the console:
If
promisesBuffer.length > 0andpendingDestination === '/setup', the navigation promise never flushed and the hypothesis holds. If the buffer is empty, the stall is downstream and this is the wrong tree.Relevant files
packages/nextjs/src/app-router/client/useInternalNavFun.ts— transition wrapper and promise bufferpackages/nextjs/src/app-router/client/useAwaitablePush.ts/useAwaitableReplace.tspackages/nextjs/src/app-router/client/ClerkProvider.tsx—__internal_onBeforeSetActive/__internal_onAfterSetActivepackages/nextjs/src/app-router/server-actions.ts—invalidateCacheActionImpact
This isn't an exotic configuration.
clerk/clerk-nextjs-app-quickstartshipsapp/protected/page.tsxdoingawait auth()thenredirect()with noloading.tsx, so the pattern is in the template we hand new users. Any post-auth landing route that redirects — onboarding gates, org selection, role-based routing — is exposed.Source of the report
Originally surfaced by an external contributor in clerk/clerk-docs#3525, which proposed documenting a
loading.tsxworkaround. The symptom report was accurate; the proposed mechanism (ClerkProviderstate clashing with a blocking transition) looks wrong, and theloading.tsxfix didn't resolve the hang in our repro.