You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
<OAuthConsent /> never mounts (blank page) when reached via <SignIn/>/<SignUp/> redirect_url in Next.js App Router: mountOAuthConsent silently no-ops during setActive()'s transitive state #9659
I have not already reached out to Clerk support via email or Discord (if you have, no need to open an issue here)
This issue is not a question, general help request, or anything other than a bug report directly related to Clerk. Please ask questions in our Discord community: https://clerk.com/discord.
// app/oauth-consent/page.tsx — verbatim from the docsimport{OAuthConsent,Show}from'@clerk/nextjs'exportconstmetadata={referrer: 'strict-origin-when-cross-origin'}exportdefaultfunctionOAuthConsentPage(){return(<Showwhen="signed-in"><OAuthConsent/></Show>)}
Clerk Dashboard → Paths → "OAuth consent" points at /oauth-consent, and an OAuth application with the consent screen enabled.
Publishable key
pk_live_Y2xlcmsuYWN0aW9uYm9vay5hcHAk
Description
Steps to reproduce:
Sign out (or open a fresh browser profile).
Start an OAuth authorization from any client. Clerk's authorization endpoint sends the signed-out user to the app's sign-in page with redirect_url pointing at the custom consent page, e.g. https://<app>/sign-in?redirect_url=https%3A%2F%2F<app>%2Foauth-consent%3Fclient_id%3D...%26scope%3D...%26redirect_uri%3D...%26state%3D...%26code_challenge%3D.... (Opening that URL directly reproduces it just as well.)
Sign in — or sign up — on that page with any strategy (Google via /sign-in/sso-callback, password, password + MFA all behave the same).
<SignIn /> completes and Clerk navigates to /oauth-consent?....
Expected behavior:
The consent screen renders.
Actual behavior:
In a production instance the page stays blank forever, with no console error (session recordings of the affected users show zero console errors). Reloading the exact same URL renders the consent screen immediately. Users who already have a session (Clerk redirects them straight to the consent page with a full page load) are not affected, which makes this look like a "new user" bug in product analytics: in our data every consent page view that immediately followed a sign-in/sign-up in the same tab got no "Allow" click (8/8, users sat on the blank page for 10–254 s, then reloaded or gave up), while every full-load arrival rendered and was allowed within 2–4 s (6/6). All sessions were Chrome/Edge on desktop.
Root cause (from reading the shipped bundles):
This is an interaction between four pieces that are all inside the SDK:
Clerk.setActive() (clerk-js) enters its transitive state before navigating — this.session = undefined; this.organization = undefined; this.user = undefined + emit — then await this.navigate(redirectUrl), and only restores the accessors (#setAccessors) after that promise resolves.
In @clerk/nextjs (App Router) navigate() for a same-origin URL is the injected routerPush (app-router/client/ClerkProvider.js → useAwaitablePush → useInternalNavFun), i.e. a client-side router.push inside startTransition, and the promise only resolves in a useEffect once isPending flips back to false — that is, after the new route has committed. So during the commit of /oauth-consent, clerk.user === undefined.
@clerk/react's ClerkHostRenderer calls mount() exactly once in componentDidMount; componentDidUpdate only forwards updateProps. Nothing re-mounts when user later becomes available.
if(!this.user){if(this.#instanceType ==='development')thrownewClerkRuntimeError(warnings.cannotRenderOAuthConsentComponentWhenUserDoesNotExist, ...);return;// production: silent no-op, never retried}
Verified in the bundle served from the CDN today (@clerk/[email protected], dist/clerk.browser.js).
The docs example makes it worse, not better: in a server component import { Show } from '@clerk/nextjs' resolves through the #components → react-server condition to app-router/server/controlComponents.js, whose Show decides with server-side auth() and puts <OAuthConsent /> straight into the RSC payload — there is no client-side isLoaded gate at all, so the component mounts at commit time, inside the transitive window, and mountOAuthConsent bails.
Net effect: for any router-integrated SDK, the documented sign-in → consent hand-off is a soft navigation that mounts <OAuthConsent /> while clerk.user is undefined, and production swallows it. Full-page loads (already-signed-in users, reloads, cross-origin client/touch redirects) initialize clerk-js from cookies before any mount, which is why they work.
Workaround we shipped: render <OAuthConsent /> from a 'use client' component gated on useAuth():
'use client'import{OAuthConsent,useAuth}from'@clerk/nextjs'exportfunctionOAuthConsentCard(){const{ isLoaded, userId }=useAuth()if(!isLoaded||!userId)return<Spinner/>// isLoaded is false during the transitive statereturn<OAuthConsentfallback={<Spinner/>}/>}
useAuth() reports isLoaded: false while sessionId/userId are undefined, so the mount is deferred until #setAccessors runs.
Suggested fixes (any one of them is enough):
mountOAuthConsent should treat user === undefined (transitive) differently from user === null (signed out) — e.g. queue the node the way premountOAuthConsentNodes does and mount once the session is restored — instead of returning permanently.
Or have ClerkHostRenderer / withClerk re-attempt the mount when the user becomes available.
Or restore the accessors before resolving the router navigation in setActive().
At minimum, log a warning in production instead of a silent return — this failed for months without a single error anywhere.
The docs example for the custom consent page should gate on the client-side isLoaded (or use <ClerkLoaded>) rather than the server Show.
Preliminary Checks
Reproduction
The reproduction is the documented custom consent page itself (https://clerk.com/docs/nextjs/reference/components/authentication/oauth-consent) plus the default
<SignIn />page, in a Next.js App Router app. Full code is inline below; I can push it to a public repo if that helps triage.Clerk Dashboard → Paths → "OAuth consent" points at
/oauth-consent, and an OAuth application with the consent screen enabled.Publishable key
pk_live_Y2xlcmsuYWN0aW9uYm9vay5hcHAk
Description
Steps to reproduce:
redirect_urlpointing at the custom consent page, e.g.https://<app>/sign-in?redirect_url=https%3A%2F%2F<app>%2Foauth-consent%3Fclient_id%3D...%26scope%3D...%26redirect_uri%3D...%26state%3D...%26code_challenge%3D.... (Opening that URL directly reproduces it just as well.)/sign-in/sso-callback, password, password + MFA all behave the same).<SignIn />completes and Clerk navigates to/oauth-consent?....Expected behavior:
The consent screen renders.
Actual behavior:
In a production instance the page stays blank forever, with no console error (session recordings of the affected users show zero console errors). Reloading the exact same URL renders the consent screen immediately. Users who already have a session (Clerk redirects them straight to the consent page with a full page load) are not affected, which makes this look like a "new user" bug in product analytics: in our data every consent page view that immediately followed a sign-in/sign-up in the same tab got no "Allow" click (8/8, users sat on the blank page for 10–254 s, then reloaded or gave up), while every full-load arrival rendered and was allowed within 2–4 s (6/6). All sessions were Chrome/Edge on desktop.
Root cause (from reading the shipped bundles):
This is an interaction between four pieces that are all inside the SDK:
Clerk.setActive()(clerk-js) enters its transitive state before navigating —this.session = undefined; this.organization = undefined; this.user = undefined+ emit — thenawait this.navigate(redirectUrl), and only restores the accessors (#setAccessors) after that promise resolves.In
@clerk/nextjs(App Router)navigate()for a same-origin URL is the injectedrouterPush(app-router/client/ClerkProvider.js→useAwaitablePush→useInternalNavFun), i.e. a client-siderouter.pushinsidestartTransition, and the promise only resolves in auseEffectonceisPendingflips back to false — that is, after the new route has committed. So during the commit of/oauth-consent,clerk.user === undefined.@clerk/react'sClerkHostRenderercallsmount()exactly once incomponentDidMount;componentDidUpdateonly forwardsupdateProps. Nothing re-mounts whenuserlater becomes available.Clerk.mountOAuthConsent(added in chore(clerk-js,shared): Add dev error when session is missing for OAuthConsent #8335, clerk-js ≥ 6.7.3) starts with:Verified in the bundle served from the CDN today (
@clerk/[email protected],dist/clerk.browser.js).The docs example makes it worse, not better: in a server component
import { Show } from '@clerk/nextjs'resolves through the#components→react-servercondition toapp-router/server/controlComponents.js, whoseShowdecides with server-sideauth()and puts<OAuthConsent />straight into the RSC payload — there is no client-sideisLoadedgate at all, so the component mounts at commit time, inside the transitive window, andmountOAuthConsentbails.Net effect: for any router-integrated SDK, the documented sign-in → consent hand-off is a soft navigation that mounts
<OAuthConsent />whileclerk.userisundefined, and production swallows it. Full-page loads (already-signed-in users, reloads, cross-originclient/touchredirects) initialize clerk-js from cookies before any mount, which is why they work.Workaround we shipped: render
<OAuthConsent />from a'use client'component gated onuseAuth():useAuth()reportsisLoaded: falsewhilesessionId/userIdareundefined, so the mount is deferred until#setAccessorsruns.Suggested fixes (any one of them is enough):
mountOAuthConsentshould treatuser === undefined(transitive) differently fromuser === null(signed out) — e.g. queue the node the waypremountOAuthConsentNodesdoes and mount once the session is restored — instead of returning permanently.ClerkHostRenderer/withClerkre-attempt the mount when the user becomes available.setActive().return— this failed for months without a single error anywhere.isLoaded(or use<ClerkLoaded>) rather than the serverShow.Environment