Skip to content

Commit e5933b5

Browse files
jacekradkoEphem
andauthored
feat(nextjs): Isolate nonce fetch in Suspense boundary for PPR support (#7773)
Co-authored-by: Fredrik Höglund <fredrik@clerk.dev>
1 parent 145252e commit e5933b5

11 files changed

Lines changed: 291 additions & 43 deletions

File tree

.changeset/thin-flies-rush.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/nextjs': patch
3+
---
4+
5+
Isolate nonce fetch in Suspense boundary for improved PPR support

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import React from 'react';
99
import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect';
1010
import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext';
1111
import type { NextClerkProviderProps } from '../../types';
12-
import { ClerkScripts } from '../../utils/clerk-script';
1312
import { canUseKeyless } from '../../utils/feature-flags';
1413
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
1514
import { RouterTelemetry } from '../../utils/router-telemetry';
1615
import { invalidateCacheAction } from '../server-actions';
16+
import { ClerkScripts } from './ClerkScripts';
1717
import { useAwaitablePush } from './useAwaitablePush';
1818
import { useAwaitableReplace } from './useAwaitableReplace';
1919

@@ -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_scriptsSlot, 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_scriptsSlot ?? <ClerkScripts />}
9393
{children}
9494
</ReactClerkProvider>
9595
</ClerkNextOptionsProvider>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { useClerk } from '@clerk/react';
2+
import React from 'react';
3+
4+
import { useClerkNextOptions } from '../../client-boundary/NextOptionsContext';
5+
import { ClerkScriptTags } from '../../utils/clerk-script-tags';
6+
7+
export function ClerkScripts() {
8+
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, nonce, prefetchUI } = useClerkNextOptions();
9+
const { domain, proxyUrl } = useClerk();
10+
11+
if (!publishableKey) {
12+
return null;
13+
}
14+
15+
return (
16+
<ClerkScriptTags
17+
publishableKey={publishableKey}
18+
clerkJSUrl={clerkJSUrl}
19+
clerkJSVersion={clerkJSVersion}
20+
clerkUIUrl={clerkUIUrl}
21+
nonce={nonce}
22+
domain={domain}
23+
proxyUrl={proxyUrl}
24+
prefetchUI={prefetchUI}
25+
/>
26+
);
27+
}
Lines changed: 29 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,57 @@ 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 scriptsSlot = 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+
) : undefined;
52+
4753
if (shouldRunAsKeyless) {
4854
return (
4955
<KeylessProvider
5056
rest={propsWithEnvs}
5157
runningWithClaimedKeys={runningWithClaimedKeys}
58+
__internal_scriptsSlot={scriptsSlot}
5259
>
5360
{children}
5461
</KeylessProvider>
5562
);
5663
}
5764

58-
return <ClientClerkProvider {...propsWithEnvs}>{children}</ClientClerkProvider>;
65+
return (
66+
<ClientClerkProvider
67+
{...propsWithEnvs}
68+
__internal_scriptsSlot={scriptsSlot}
69+
>
70+
{children}
71+
</ClientClerkProvider>
72+
);
5973
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { headers } from 'next/headers';
2+
import React from 'react';
3+
4+
import { ClerkScriptTags } from '../../utils/clerk-script-tags';
5+
import { getScriptNonceFromHeader, isPrerenderingBailout } from './utils';
6+
7+
async function getNonce(): Promise<string> {
8+
try {
9+
const headersList = await headers();
10+
const nonce = headersList.get('X-Nonce');
11+
return nonce
12+
? nonce
13+
: // Fallback to extracting from CSP header
14+
getScriptNonceFromHeader(headersList.get('Content-Security-Policy') || '') || '';
15+
} catch (e) {
16+
if (isPrerenderingBailout(e)) {
17+
throw e;
18+
}
19+
// Graceful degradation — scripts load without nonce
20+
return '';
21+
}
22+
}
23+
24+
type DynamicClerkScriptsProps = {
25+
publishableKey: string;
26+
clerkJSUrl?: string;
27+
clerkJSVersion?: string;
28+
clerkUIUrl?: string;
29+
domain?: string;
30+
proxyUrl?: string;
31+
prefetchUI?: boolean;
32+
};
33+
34+
/**
35+
* Server component that fetches nonce from headers and renders Clerk scripts.
36+
* This component should be wrapped in a Suspense boundary to isolate the dynamic
37+
* nonce fetching from the rest of the page, allowing static rendering/PPR to work.
38+
*/
39+
export async function DynamicClerkScripts(props: DynamicClerkScriptsProps) {
40+
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, domain, proxyUrl, prefetchUI } = props;
41+
42+
if (!publishableKey) {
43+
return null;
44+
}
45+
46+
const nonce = await getNonce();
47+
48+
return (
49+
<ClerkScriptTags
50+
publishableKey={publishableKey}
51+
clerkJSUrl={clerkJSUrl}
52+
clerkJSVersion={clerkJSVersion}
53+
clerkUIUrl={clerkUIUrl}
54+
nonce={nonce}
55+
domain={domain}
56+
proxyUrl={proxyUrl}
57+
prefetchUI={prefetchUI}
58+
/>
59+
);
60+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import type React from 'react';
2+
import { renderToStaticMarkup } from 'react-dom/server';
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { DynamicClerkScripts } from '../DynamicClerkScripts';
6+
7+
vi.mock('next/headers', () => ({
8+
headers: vi.fn(),
9+
}));
10+
11+
import { headers } from 'next/headers';
12+
13+
const mockHeaders = headers as unknown as ReturnType<typeof vi.fn>;
14+
15+
const render = async (element: Promise<React.JSX.Element | null>) => {
16+
const resolved = await element;
17+
if (!resolved) {
18+
return '';
19+
}
20+
return renderToStaticMarkup(resolved);
21+
};
22+
23+
const defaultProps = {
24+
publishableKey: 'pk_test_123',
25+
};
26+
27+
describe('DynamicClerkScripts', () => {
28+
afterEach(() => {
29+
vi.clearAllMocks();
30+
});
31+
32+
it('returns null when publishableKey is empty', async () => {
33+
const html = await render(DynamicClerkScripts({ publishableKey: '' }));
34+
expect(html).toBe('');
35+
});
36+
37+
it('uses X-Nonce header when present', async () => {
38+
mockHeaders.mockResolvedValue(
39+
new Map([
40+
['X-Nonce', 'test-nonce-123'],
41+
['Content-Security-Policy', ''],
42+
]),
43+
);
44+
45+
const html = await render(DynamicClerkScripts(defaultProps));
46+
expect(html).toContain('nonce="test-nonce-123"');
47+
});
48+
49+
it('falls back to CSP header when X-Nonce is absent', async () => {
50+
mockHeaders.mockResolvedValue(
51+
new Map([
52+
['X-Nonce', null],
53+
['Content-Security-Policy', "script-src 'nonce-csp-nonce-456'"],
54+
]),
55+
);
56+
57+
const html = await render(DynamicClerkScripts(defaultProps));
58+
expect(html).toContain('nonce="csp-nonce-456"');
59+
});
60+
61+
it('renders scripts without a nonce value when neither X-Nonce nor CSP header is present', async () => {
62+
mockHeaders.mockResolvedValue(
63+
new Map([
64+
['X-Nonce', null],
65+
['Content-Security-Policy', ''],
66+
]),
67+
);
68+
69+
const html = await render(DynamicClerkScripts(defaultProps));
70+
expect(html).toContain('data-clerk-js-script');
71+
expect(html).not.toContain('nonce="test');
72+
expect(html).not.toContain('nonce="csp');
73+
});
74+
75+
it('rethrows prerendering bailout errors', async () => {
76+
mockHeaders.mockRejectedValue(new Error('Dynamic server usage: headers'));
77+
78+
await expect(render(DynamicClerkScripts(defaultProps))).rejects.toThrow('Dynamic server usage: headers');
79+
});
80+
81+
it('gracefully degrades when headers() throws a non-bailout error', async () => {
82+
mockHeaders.mockRejectedValue(new Error('some unexpected error'));
83+
84+
const html = await render(DynamicClerkScripts(defaultProps));
85+
expect(html).toContain('data-clerk-js-script');
86+
expect(html).not.toContain('nonce="test');
87+
expect(html).not.toContain('nonce="csp');
88+
});
89+
});

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_scriptsSlot?: React.ReactNode;
3536
}>;
3637

3738
export const KeylessProvider = async (props: KeylessProviderProps) => {
38-
const { rest, runningWithClaimedKeys, children } = props;
39+
const { rest, runningWithClaimedKeys, __internal_scriptsSlot, 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_scriptsSlot={__internal_scriptsSlot}
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_scriptsSlot={__internal_scriptsSlot}
7174
>
7275
{children}
7376
</ClientClerkProvider>

packages/nextjs/src/pages/ClerkProvider.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ import React from 'react';
88
import { useSafeLayoutEffect } from '../client-boundary/hooks/useSafeLayoutEffect';
99
import { ClerkNextOptionsProvider } from '../client-boundary/NextOptionsContext';
1010
import type { NextClerkProviderProps } from '../types';
11-
import { ClerkScripts } from '../utils/clerk-script';
1211
import { invalidateNextRouterCache } from '../utils/invalidateNextRouterCache';
1312
import { mergeNextClerkPropsWithEnv } from '../utils/mergeNextClerkPropsWithEnv';
1413
import { removeBasePath } from '../utils/removeBasePath';
1514
import { RouterTelemetry } from '../utils/router-telemetry';
15+
import { ClerkScripts } from './ClerkScripts';
1616

1717
setErrorThrowerOptions({ packageName: PACKAGE_NAME });
1818
setClerkJSLoadingErrorPackageName(PACKAGE_NAME);
@@ -56,7 +56,7 @@ export function ClerkProvider<TUi extends Ui = Ui>({ children, ...props }: NextC
5656
initialState={initialState}
5757
>
5858
<RouterTelemetry />
59-
<ClerkScripts router='pages' />
59+
<ClerkScripts />
6060
{children}
6161
</ReactClerkProvider>
6262
</ClerkNextOptionsProvider>

0 commit comments

Comments
 (0)