Skip to content

Post-auth navigation into a server-redirecting route hangs the App Router #9405

Description

@manovotny

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_onAfterSetActiverouter.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.tsinvalidateCacheAction

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingnextjs

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions