From 892a4d2913fa4aabcb5fd0430528dec2fb4f3089 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 5 Feb 2026 09:58:29 -0600 Subject: [PATCH 1/6] feat(nextjs): Isolate nonce fetch in Suspense boundary for PPR support Move nonce fetching from the server ClerkProvider's main body into a separate DynamicClerkScripts server component wrapped in Suspense. This allows pages using dynamic=true to remain statically renderable and compatible with PPR/cacheComponents. - Create DynamicClerkScripts async server component - Add getNonce cached function to utils - Skip client ClerkScripts when server scripts are used - Pass __internal_skipScripts through KeylessProvider --- .../src/app-router/client/ClerkProvider.tsx | 4 +- .../src/app-router/server/ClerkProvider.tsx | 46 +++++++++---- .../app-router/server/DynamicClerkScripts.tsx | 69 +++++++++++++++++++ .../app-router/server/keyless-provider.tsx | 5 +- .../nextjs/src/app-router/server/utils.ts | 17 +++++ packages/nextjs/src/types.ts | 6 ++ 6 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 3177c997026..a743f73b4fa 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -26,7 +26,7 @@ const LazyCreateKeylessApplication = dynamic(() => ); const NextClientClerkProvider = (props: NextClerkProviderProps) => { - const { __internal_invokeMiddlewareOnAuthStateChange = true, children } = props; + const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_skipScripts = false, children } = props; const router = useRouter(); const push = useAwaitablePush(); const replace = useAwaitableReplace(); @@ -89,7 +89,7 @@ const NextClientClerkProvider = (props: NextClerkProviderPr - + {!__internal_skipScripts && } {children} diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx index 1a9da428cff..59d4c0f5b94 100644 --- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx @@ -1,14 +1,14 @@ import type { Ui } from '@clerk/react/internal'; import type { InitialState, Without } from '@clerk/shared/types'; -import { headers } from 'next/headers'; -import React from 'react'; +import React, { Suspense } from 'react'; import { getDynamicAuthData } from '../../server/buildClerkProps'; import type { NextClerkProviderProps } from '../../types'; import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv'; import { ClientClerkProvider } from '../client/ClerkProvider'; +import { DynamicClerkScripts } from './DynamicClerkScripts'; import { getKeylessStatus, KeylessProvider } from './keyless-provider'; -import { buildRequestLike, getScriptNonceFromHeader } from './utils'; +import { buildRequestLike } from './utils'; const getDynamicClerkState = React.cache(async function getDynamicClerkState() { const request = await buildRequestLike(); @@ -17,43 +17,59 @@ const getDynamicClerkState = React.cache(async function getDynamicClerkState() { return data; }); -const getNonceHeaders = React.cache(async function getNonceHeaders() { - const headersList = await headers(); - const nonce = headersList.get('X-Nonce'); - return nonce - ? nonce - : // Fallback to extracting from CSP header - getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || ''; -}); - export async function ClerkProvider( props: Without, '__internal_invokeMiddlewareOnAuthStateChange'>, ) { const { children, dynamic, ...rest } = props; const statePromiseOrValue = dynamic ? getDynamicClerkState() : undefined; - const noncePromiseOrValue = dynamic ? getNonceHeaders() : ''; const propsWithEnvs = mergeNextClerkPropsWithEnv({ ...rest, // Even though we always cast to InitialState here, this might still be a promise. // While not reflected in the public types, we do support this for React >= 19 for internal use. initialState: statePromiseOrValue as InitialState | undefined, - nonce: await noncePromiseOrValue, }); const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs); + // When dynamic mode is enabled, render scripts in a Suspense boundary to isolate + // the nonce fetching (which calls headers()) from the rest of the page. + // This allows the page to remain statically renderable / use PPR. + const dynamicScripts = dynamic ? ( + + + + ) : null; + if (shouldRunAsKeyless) { return ( + {dynamicScripts} {children} ); } - return {children}; + return ( + + {dynamicScripts} + {children} + + ); } diff --git a/packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx b/packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx new file mode 100644 index 00000000000..ef8b0ed37fb --- /dev/null +++ b/packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx @@ -0,0 +1,69 @@ +import { buildClerkJSScriptAttributes, clerkJSScriptUrl, clerkUIScriptUrl } from '@clerk/react/internal'; +import React from 'react'; + +import { getNonce } from './utils'; + +type DynamicClerkScriptsProps = { + publishableKey: string; + clerkJSUrl?: string; + clerkJSVersion?: string; + clerkUIUrl?: string; + domain?: string; + proxyUrl?: string; + prefetchUI?: boolean; +}; + +/** + * Server component that fetches nonce from headers and renders Clerk scripts. + * This component should be wrapped in a Suspense boundary to isolate the dynamic + * nonce fetching from the rest of the page, allowing static rendering/PPR to work. + */ +export async function DynamicClerkScripts(props: DynamicClerkScriptsProps) { + const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, domain, proxyUrl, prefetchUI } = props; + + if (!publishableKey) { + return null; + } + + const nonce = await getNonce(); + + const opts = { + publishableKey, + clerkJSUrl, + clerkJSVersion, + clerkUIUrl, + nonce, + domain, + proxyUrl, + }; + + const scriptUrl = clerkJSScriptUrl(opts); + const attributes = buildClerkJSScriptAttributes(opts); + + return ( + <> +