Skip to content

Commit 892a4d2

Browse files
committed
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
1 parent 38def4f commit 892a4d2

6 files changed

Lines changed: 129 additions & 18 deletions

File tree

packages/nextjs/src/app-router/client/ClerkProvider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const LazyCreateKeylessApplication = dynamic(() =>
2626
);
2727

2828
const NextClientClerkProvider = <TUi extends Ui = Ui>(props: NextClerkProviderProps<TUi>) => {
29-
const { __internal_invokeMiddlewareOnAuthStateChange = true, children } = props;
29+
const { __internal_invokeMiddlewareOnAuthStateChange = true, __internal_skipScripts = false, children } = props;
3030
const router = useRouter();
3131
const push = useAwaitablePush();
3232
const replace = useAwaitableReplace();
@@ -89,7 +89,7 @@ const NextClientClerkProvider = <TUi extends Ui = Ui>(props: NextClerkProviderPr
8989
<ClerkNextOptionsProvider options={mergedProps}>
9090
<ReactClerkProvider {...mergedProps}>
9191
<RouterTelemetry />
92-
<ClerkScripts router='app' />
92+
{!__internal_skipScripts && <ClerkScripts router='app' />}
9393
{children}
9494
</ReactClerkProvider>
9595
</ClerkNextOptionsProvider>
Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { Ui } from '@clerk/react/internal';
22
import type { InitialState, Without } from '@clerk/shared/types';
3-
import { headers } from 'next/headers';
4-
import React from 'react';
3+
import React, { Suspense } from 'react';
54

65
import { getDynamicAuthData } from '../../server/buildClerkProps';
76
import type { NextClerkProviderProps } from '../../types';
87
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
98
import { ClientClerkProvider } from '../client/ClerkProvider';
9+
import { DynamicClerkScripts } from './DynamicClerkScripts';
1010
import { getKeylessStatus, KeylessProvider } from './keyless-provider';
11-
import { buildRequestLike, getScriptNonceFromHeader } from './utils';
11+
import { buildRequestLike } from './utils';
1212

1313
const getDynamicClerkState = React.cache(async function getDynamicClerkState() {
1414
const request = await buildRequestLike();
@@ -17,43 +17,59 @@ const getDynamicClerkState = React.cache(async function getDynamicClerkState() {
1717
return data;
1818
});
1919

20-
const getNonceHeaders = React.cache(async function getNonceHeaders() {
21-
const headersList = await headers();
22-
const nonce = headersList.get('X-Nonce');
23-
return nonce
24-
? nonce
25-
: // Fallback to extracting from CSP header
26-
getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || '';
27-
});
28-
2920
export async function ClerkProvider<TUi extends Ui = Ui>(
3021
props: Without<NextClerkProviderProps<TUi>, '__internal_invokeMiddlewareOnAuthStateChange'>,
3122
) {
3223
const { children, dynamic, ...rest } = props;
3324

3425
const statePromiseOrValue = dynamic ? getDynamicClerkState() : undefined;
35-
const noncePromiseOrValue = dynamic ? getNonceHeaders() : '';
3626

3727
const propsWithEnvs = mergeNextClerkPropsWithEnv({
3828
...rest,
3929
// Even though we always cast to InitialState here, this might still be a promise.
4030
// While not reflected in the public types, we do support this for React >= 19 for internal use.
4131
initialState: statePromiseOrValue as InitialState | undefined,
42-
nonce: await noncePromiseOrValue,
4332
});
4433

4534
const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs);
4635

36+
// When dynamic mode is enabled, render scripts in a Suspense boundary to isolate
37+
// the nonce fetching (which calls headers()) from the rest of the page.
38+
// This allows the page to remain statically renderable / use PPR.
39+
const dynamicScripts = dynamic ? (
40+
<Suspense>
41+
<DynamicClerkScripts
42+
publishableKey={propsWithEnvs.publishableKey}
43+
clerkJSUrl={propsWithEnvs.clerkJSUrl}
44+
clerkJSVersion={propsWithEnvs.clerkJSVersion}
45+
clerkUIUrl={propsWithEnvs.clerkUIUrl}
46+
domain={propsWithEnvs.domain}
47+
proxyUrl={propsWithEnvs.proxyUrl}
48+
prefetchUI={propsWithEnvs.prefetchUI}
49+
/>
50+
</Suspense>
51+
) : null;
52+
4753
if (shouldRunAsKeyless) {
4854
return (
4955
<KeylessProvider
5056
rest={propsWithEnvs}
5157
runningWithClaimedKeys={runningWithClaimedKeys}
58+
__internal_skipScripts={dynamic}
5259
>
60+
{dynamicScripts}
5361
{children}
5462
</KeylessProvider>
5563
);
5664
}
5765

58-
return <ClientClerkProvider {...propsWithEnvs}>{children}</ClientClerkProvider>;
66+
return (
67+
<ClientClerkProvider
68+
{...propsWithEnvs}
69+
__internal_skipScripts={dynamic}
70+
>
71+
{dynamicScripts}
72+
{children}
73+
</ClientClerkProvider>
74+
);
5975
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { buildClerkJSScriptAttributes, clerkJSScriptUrl, clerkUIScriptUrl } from '@clerk/react/internal';
2+
import React from 'react';
3+
4+
import { getNonce } from './utils';
5+
6+
type DynamicClerkScriptsProps = {
7+
publishableKey: string;
8+
clerkJSUrl?: string;
9+
clerkJSVersion?: string;
10+
clerkUIUrl?: string;
11+
domain?: string;
12+
proxyUrl?: string;
13+
prefetchUI?: boolean;
14+
};
15+
16+
/**
17+
* Server component that fetches nonce from headers and renders Clerk scripts.
18+
* This component should be wrapped in a Suspense boundary to isolate the dynamic
19+
* nonce fetching from the rest of the page, allowing static rendering/PPR to work.
20+
*/
21+
export async function DynamicClerkScripts(props: DynamicClerkScriptsProps) {
22+
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, domain, proxyUrl, prefetchUI } = props;
23+
24+
if (!publishableKey) {
25+
return null;
26+
}
27+
28+
const nonce = await getNonce();
29+
30+
const opts = {
31+
publishableKey,
32+
clerkJSUrl,
33+
clerkJSVersion,
34+
clerkUIUrl,
35+
nonce,
36+
domain,
37+
proxyUrl,
38+
};
39+
40+
const scriptUrl = clerkJSScriptUrl(opts);
41+
const attributes = buildClerkJSScriptAttributes(opts);
42+
43+
return (
44+
<>
45+
<script
46+
src={scriptUrl}
47+
data-clerk-js-script
48+
async
49+
crossOrigin='anonymous'
50+
{...attributes}
51+
/>
52+
{/* Use <link rel='preload'> instead of <script> for the UI bundle.
53+
This tells the browser to download the resource immediately (high priority)
54+
but doesn't execute it, avoiding race conditions with __clerkSharedModules
55+
registration (which happens when React code runs @clerk/ui/register).
56+
When loadClerkUIScript() later adds a <script> tag, the browser uses the
57+
cached resource and executes it without re-downloading. */}
58+
{prefetchUI !== false && (
59+
<link
60+
rel='preload'
61+
href={clerkUIScriptUrl(opts)}
62+
as='script'
63+
crossOrigin='anonymous'
64+
nonce={nonce}
65+
/>
66+
)}
67+
</>
68+
);
69+
}

packages/nextjs/src/app-router/server/keyless-provider.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,11 @@ export async function getKeylessStatus(
3232
type KeylessProviderProps = PropsWithChildren<{
3333
rest: Without<NextClerkProviderProps, '__internal_invokeMiddlewareOnAuthStateChange' | 'children'>;
3434
runningWithClaimedKeys: boolean;
35+
__internal_skipScripts?: boolean;
3536
}>;
3637

3738
export const KeylessProvider = async (props: KeylessProviderProps) => {
38-
const { rest, runningWithClaimedKeys, children } = props;
39+
const { rest, runningWithClaimedKeys, __internal_skipScripts, children } = props;
3940

4041
// NOTE: Create or read keys on every render. Usually this means only on hard refresh or hard navigations.
4142
const newOrReadKeys = await import('../../server/keyless-node.js')
@@ -52,6 +53,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
5253
<ClientClerkProvider
5354
{...mergeNextClerkPropsWithEnv(rest)}
5455
disableKeyless
56+
__internal_skipScripts={__internal_skipScripts}
5557
>
5658
{children}
5759
</ClientClerkProvider>
@@ -68,6 +70,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
6870
// Explicitly use `null` instead of `undefined` here to avoid persisting `deleteKeylessAction` during merging of options.
6971
__internal_keyless_dismissPrompt: runningWithClaimedKeys ? deleteKeylessAction : null,
7072
})}
73+
__internal_skipScripts={__internal_skipScripts}
7174
>
7275
{children}
7376
</ClientClerkProvider>

packages/nextjs/src/app-router/server/utils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest } from 'next/server';
2+
import React from 'react';
23

34
export const isPrerenderingBailout = (e: unknown) => {
45
if (!(e instanceof Error) || !('message' in e)) {
@@ -83,3 +84,19 @@ export function getScriptNonceFromHeader(cspHeaderValue: string): string | undef
8384

8485
return nonce;
8586
}
87+
88+
/**
89+
* Fetches the nonce from request headers.
90+
* Uses React.cache to deduplicate calls within the same request.
91+
*/
92+
export const getNonce = React.cache(async function getNonce(): Promise<string> {
93+
// Dynamically import next/headers
94+
// @ts-expect-error: Cannot find module 'next/headers' or its corresponding type declarations.ts(2307)
95+
const { headers } = await import('next/headers');
96+
const headersList = await headers();
97+
const nonce = headersList.get('X-Nonce');
98+
return nonce
99+
? nonce
100+
: // Fallback to extracting from CSP header
101+
getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || '';
102+
});

packages/nextjs/src/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,10 @@ export type NextClerkProviderProps<TUi extends Ui = Ui> = Without<ClerkProviderP
2323
* @default false
2424
*/
2525
dynamic?: boolean;
26+
/**
27+
* @internal
28+
* If set to true, the client ClerkProvider will not render ClerkScripts.
29+
* Used when scripts are rendered server-side in a Suspense boundary.
30+
*/
31+
__internal_skipScripts?: boolean;
2632
};

0 commit comments

Comments
 (0)