Skip to content
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
25 changes: 25 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,27 @@ 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.
*/
export const getNonce = React.cache(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 '';
}
});
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