Skip to content

<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

Description

@4bmis

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.

// app/oauth-consent/page.tsx — verbatim from the docs
import { OAuthConsent, Show } from '@clerk/nextjs'

export const metadata = { referrer: 'strict-origin-when-cross-origin' }

export default function OAuthConsentPage() {
  return (
    <Show when="signed-in">
      <OAuthConsent />
    </Show>
  )
}
// app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from '@clerk/nextjs'
export default function Page() { return <SignIn /> }

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:

  1. Sign out (or open a fresh browser profile).
  2. 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.)
  3. Sign in — or sign up — on that page with any strategy (Google via /sign-in/sso-callback, password, password + MFA all behave the same).
  4. <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:

  1. 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.

  2. In @clerk/nextjs (App Router) navigate() for a same-origin URL is the injected routerPush (app-router/client/ClerkProvider.jsuseAwaitablePushuseInternalNavFun), 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.

  3. @clerk/react's ClerkHostRenderer calls mount() exactly once in componentDidMount; componentDidUpdate only forwards updateProps. Nothing re-mounts when user later becomes available.

  4. 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:

    if (!this.user) {
      if (this.#instanceType === 'development') throw new ClerkRuntimeError(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 #componentsreact-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'

export function OAuthConsentCard() {
  const { isLoaded, userId } = useAuth()
  if (!isLoaded || !userId) return <Spinner />   // isLoaded is false during the transitive state
  return <OAuthConsent fallback={<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.

Environment

System:
  OS: macOS 26.3
  CPU: (10) arm64 Apple M5
Binaries:
  Node: 25.8.1
  npm: 11.11.0
  pnpm: 10.24.0
Browsers:
  Chrome: 152.0.7977.66
  Safari: 26.3
npmPackages:
  @clerk/nextjs: ^7.8.2 => 7.8.2
  @clerk/react: 6.14.7 (via @clerk/nextjs)
  @clerk/shared: 4.30.1
  @clerk/ui: ^1.30.8 => 1.30.8
  @clerk/localizations: ^4.15.7
  next: ^16.3.4
  react: ^19.2.4
  react-dom: ^19.2.4
Runtime (loaded from the Clerk CDN via the frontend API host):
  @clerk/clerk-js: 6.31.0 (npm/@clerk/clerk-js@6 → 6.31.0)
  @clerk/ui: npm/@clerk/ui@1

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions