Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/thin-flies-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Isolate nonce fetch in Suspense boundary for PPR support and guard `React.cache` for non-RSC environments
4 changes: 2 additions & 2 deletions packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const LazyCreateKeylessApplication = dynamic(() =>
);

const NextClientClerkProvider = <TUi extends Ui = Ui>(props: NextClerkProviderProps<TUi>) => {
const { __internal_invokeMiddlewareOnAuthStateChange = true, children } = props;
const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_skipScripts = false, children } = props;
const router = useRouter();
const push = useAwaitablePush();
const replace = useAwaitableReplace();
Expand Down Expand Up @@ -89,7 +89,7 @@ const NextClientClerkProvider = <TUi extends Ui = Ui>(props: NextClerkProviderPr
<ClerkNextOptionsProvider options={mergedProps}>
<ReactClerkProvider {...mergedProps}>
<RouterTelemetry />
<ClerkScripts router='app' />
{!__internal_skipScripts && <ClerkScripts router='app' />}
{children}
</ReactClerkProvider>
</ClerkNextOptionsProvider>
Expand Down
46 changes: 31 additions & 15 deletions packages/nextjs/src/app-router/server/ClerkProvider.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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<TUi extends Ui = Ui>(
props: Without<NextClerkProviderProps<TUi>, '__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 ? (
<Suspense>
<DynamicClerkScripts
publishableKey={propsWithEnvs.publishableKey}
clerkJSUrl={propsWithEnvs.clerkJSUrl}
clerkJSVersion={propsWithEnvs.clerkJSVersion}
clerkUIUrl={propsWithEnvs.clerkUIUrl}
domain={propsWithEnvs.domain}
proxyUrl={propsWithEnvs.proxyUrl}
prefetchUI={propsWithEnvs.prefetchUI}
/>
</Suspense>
) : null;

if (shouldRunAsKeyless) {
return (
<KeylessProvider
rest={propsWithEnvs}
runningWithClaimedKeys={runningWithClaimedKeys}
__internal_skipScripts={dynamic}
>
{dynamicScripts}
{children}
</KeylessProvider>
);
}

return <ClientClerkProvider {...propsWithEnvs}>{children}</ClientClerkProvider>;
return (
<ClientClerkProvider
{...propsWithEnvs}
__internal_skipScripts={dynamic}
>
{dynamicScripts}
{children}
</ClientClerkProvider>
);
}
42 changes: 42 additions & 0 deletions packages/nextjs/src/app-router/server/DynamicClerkScripts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import React from 'react';

import { ClerkScriptTags } from '../../utils/clerk-script-tags';
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();

return (
<ClerkScriptTags
publishableKey={publishableKey}
clerkJSUrl={clerkJSUrl}
clerkJSVersion={clerkJSVersion}
clerkUIUrl={clerkUIUrl}
nonce={nonce}
domain={domain}
proxyUrl={proxyUrl}
prefetchUI={prefetchUI}
/>
);
}
5 changes: 4 additions & 1 deletion packages/nextjs/src/app-router/server/keyless-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ export async function getKeylessStatus(
type KeylessProviderProps = PropsWithChildren<{
rest: Without<NextClerkProviderProps, '__internal_invokeMiddlewareOnAuthStateChange' | 'children'>;
runningWithClaimedKeys: boolean;
__internal_skipScripts?: boolean;
}>;

export const KeylessProvider = async (props: KeylessProviderProps) => {
const { rest, runningWithClaimedKeys, children } = props;
const { rest, runningWithClaimedKeys, __internal_skipScripts, children } = props;

// NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations.
const newOrReadKeys = await import('../../server/keyless-node.js')
Expand All @@ -52,6 +53,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
<ClientClerkProvider
{...mergeNextClerkPropsWithEnv(rest)}
disableKeyless
__internal_skipScripts={__internal_skipScripts}
>
{children}
</ClientClerkProvider>
Expand All @@ -68,6 +70,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
// Explicitly use `null` instead of `undefined` here to avoid persisting `deleteKeylessAction` during merging of options.
__internal_keyless_dismissPrompt: runningWithClaimedKeys ? deleteKeylessAction : null,
})}
__internal_skipScripts={__internal_skipScripts}
>
{children}
</ClientClerkProvider>
Expand Down
29 changes: 29 additions & 0 deletions packages/nextjs/src/app-router/server/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest } from 'next/server';
import React from 'react';

const CLERK_USE_CACHE_MARKER = Symbol.for('clerk_use_cache_error');

Expand Down Expand Up @@ -151,3 +152,31 @@ export function getScriptNonceFromHeader(cspHeaderValue: string): string | undef

return nonce;
}

/**
* Fetches the nonce from request headers.
* Uses React.cache to deduplicate calls within the same request.
*/
// React.cache is only available in RSC environments; provide a no-op fallback for tests/non-RSC contexts.
const reactCache =
typeof React.cache === 'function' ? React.cache : <T extends (...args: any[]) => any>(fn: T): T => fn;

export const getNonce = reactCache(async function getNonce(): Promise<string> {
try {
// Dynamically import next/headers
// @ts-expect-error: Cannot find module 'next/headers' or its corresponding type declarations.ts(2307)
const { headers } = await import('next/headers');
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') || '') || '';
} catch (e) {
if (isPrerenderingBailout(e)) {
throw e;
}
// Graceful degradation — scripts load without nonce
return '';
}
Comment on lines 156 to 181
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add automated coverage for getNonce behavior.
No tests are included for the new nonce retrieval/caching paths; please add tests covering X-Nonce presence, CSP fallback, and prerendering bailout handling.

As per coding guidelines, “If there are no tests added or modified as part of the PR, please suggest that tests be added to cover the changes.”

🤖 Prompt for AI Agents
In `@packages/nextjs/src/app-router/server/utils.ts` around lines 156 - 180, Add
automated tests for getNonce covering three scenarios: (1) when the request
provides X-Nonce header ensure getNonce returns that value and caching via
reactCache deduplicates repeated calls; (2) when X-Nonce is absent but
Content-Security-Policy contains a script-nonce ensure getNonce falls back to
getScriptNonceFromHeader and returns the extracted nonce; and (3) when the
dynamic import throws a prerendering bailout (isPrerenderingBailout returns
true) ensure the bailout is rethrown. In tests, mock the dynamic import of
'next/headers' to return a headers() object with get() behavior, spy/override
getScriptNonceFromHeader and isPrerenderingBailout to simulate CSP and bailout
cases, and verify caching semantics by calling getNonce multiple times in the
same test to confirm reactCache deduplication.

});
6 changes: 6 additions & 0 deletions packages/nextjs/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ export type NextClerkProviderProps<TUi extends Ui = Ui> = Without<ClerkProviderP
* @default false
*/
dynamic?: boolean;
/**
* @internal
* If set to true, the client ClerkProvider will not render ClerkScripts.
* Used when scripts are rendered server-side in a Suspense boundary.
*/
__internal_skipScripts?: boolean;
};
59 changes: 59 additions & 0 deletions packages/nextjs/src/utils/clerk-script-tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { buildClerkJSScriptAttributes, clerkJSScriptUrl, clerkUIScriptUrl } from '@clerk/shared/loadClerkJsScript';
import React from 'react';

type ClerkScriptTagsProps = {
publishableKey: string;
clerkJSUrl?: string;
clerkJSVersion?: string;
clerkUIUrl?: string;
nonce?: string;
domain?: string;
proxyUrl?: string;
prefetchUI?: boolean;
};

/**
* Pure component that renders the Clerk script tags.
* Shared between `ClerkScripts` (client, app router) and `DynamicClerkScripts` (server).
* No hooks or client-only imports — safe for both server and client components.
*/
export function ClerkScriptTags(props: ClerkScriptTagsProps) {
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, nonce, domain, proxyUrl, prefetchUI } = props;

const opts = {
publishableKey,
clerkJSUrl,
clerkJSVersion,
clerkUIUrl,
nonce,
domain,
proxyUrl,
};

return (
<>
<script
src={clerkJSScriptUrl(opts)}
data-clerk-js-script
async
crossOrigin='anonymous'
{...buildClerkJSScriptAttributes(opts)}
/>
{/* Use <link rel='preload'> instead of <script> for the UI bundle.
This tells the browser to download the resource immediately (high priority)
but doesn't execute it, avoiding race conditions with __clerkSharedModules
registration (which happens when React code runs @clerk/ui/register).
When loadClerkUIScript() later adds a <script> tag, the browser uses the
cached resource and executes it without re-downloading. */}
{prefetchUI !== false && (
<link
rel='preload'
href={clerkUIScriptUrl(opts)}
as='script'
crossOrigin='anonymous'
nonce={nonce}
/>
)}
</>
);
}
42 changes: 21 additions & 21 deletions packages/nextjs/src/utils/clerk-script.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,47 +4,54 @@ import NextScript from 'next/script';
import React from 'react';

import { useClerkNextOptions } from '../client-boundary/NextOptionsContext';
import { ClerkScriptTags } from './clerk-script-tags';

type ClerkScriptProps = {
scriptUrl: string;
attributes: Record<string, string>;
dataAttribute: string;
router: 'app' | 'pages';
};

function ClerkScript(props: ClerkScriptProps) {
const { scriptUrl, attributes, dataAttribute, router } = props;

/**
* Notes:
* `next/script` in 13.x.x when used with App Router will fail to pass any of our `data-*` attributes, resulting in errors
* Nextjs App Router will automatically move inline scripts inside `<head/>`
* Using the `nextjs/script` for App Router with the `beforeInteractive` strategy will throw an error because our custom script will be mounted outside the `html` tag.
*/
const Script = router === 'app' ? 'script' : NextScript;
const { scriptUrl, attributes, dataAttribute } = props;

return (
<Script
<NextScript
src={scriptUrl}
{...{ [dataAttribute]: true }}
async
// `nextjs/script` will add defer by default and does not get removed when async is true
defer={router === 'pages' ? false : undefined}
defer={false}
crossOrigin='anonymous'
strategy={router === 'pages' ? 'beforeInteractive' : undefined}
strategy='beforeInteractive'
{...attributes}
/>
);
}

export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] }) {
export function ClerkScripts({ router }: { router: 'app' | 'pages' }) {
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, nonce, prefetchUI } = useClerkNextOptions();
const { domain, proxyUrl } = useClerk();

if (!publishableKey) {
return null;
}

if (router === 'app') {
return (
<ClerkScriptTags
publishableKey={publishableKey}
clerkJSUrl={clerkJSUrl}
clerkJSVersion={clerkJSVersion}
clerkUIUrl={clerkUIUrl}
nonce={nonce}
domain={domain}
proxyUrl={proxyUrl}
prefetchUI={prefetchUI}
/>
);
}

const opts = {
publishableKey,
clerkJSUrl,
Expand All @@ -61,14 +68,7 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] })
scriptUrl={clerkJSScriptUrl(opts)}
attributes={buildClerkJSScriptAttributes(opts)}
dataAttribute='data-clerk-js-script'
router={router}
/>
{/* Use <link rel='preload'> instead of <script> for the UI bundle.
This tells the browser to download the resource immediately (high priority)
but doesn't execute it, avoiding race conditions with __clerkSharedModules
registration (which happens when React code runs @clerk/ui/register).
When loadClerkUIScript() later adds a <script> tag, the browser uses the
cached resource and executes it without re-downloading. */}
{prefetchUI !== false && (
<link
rel='preload'
Expand Down
Loading