diff --git a/.changeset/quiet-rockets-invite.md b/.changeset/quiet-rockets-invite.md
new file mode 100644
index 00000000000..9d432012a81
--- /dev/null
+++ b/.changeset/quiet-rockets-invite.md
@@ -0,0 +1,5 @@
+---
+'@clerk/nextjs': patch
+---
+
+Improve support for apps that opt-in to using Next.js new `cacheComponents` feature.
diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx
index cb3f398fe0a..08218a4c322 100644
--- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx
+++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx
@@ -15,7 +15,6 @@ import { canUseKeyless } from '../../utils/feature-flags';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
import { RouterTelemetry } from '../../utils/router-telemetry';
import { isNextWithUnstableServerActions } from '../../utils/sdk-versions';
-import { detectKeylessEnvDriftAction } from '../keyless-actions';
import { invalidateCacheAction } from '../server-actions';
import { useAwaitablePush } from './useAwaitablePush';
import { useAwaitableReplace } from './useAwaitableReplace';
@@ -28,6 +27,11 @@ const LazyCreateKeylessApplication = dynamic(() =>
import('./keyless-creator-reader.js').then(m => m.KeylessCreatorOrReader),
);
+/**
+ * LazyKeylessDriftDetector should only be loaded if the conditions below are met.
+ */
+const LazyKeylessDriftDetector = dynamic(() => import('./keyless-drift-detector.js').then(m => m.KeylessDriftDetector));
+
const NextClientClerkProvider = (props: NextClerkProviderProps) => {
if (isNextWithUnstableServerActions) {
const deprecationWarning = `Clerk:\nYour current Next.js version (${nextPackage.version}) will be deprecated in the next major release of "@clerk/nextjs". Please upgrade to next@14.1.0 or later.`;
@@ -44,13 +48,6 @@ const NextClientClerkProvider = (props: NextClerkProviderProps) => {
const replace = useAwaitableReplace();
const [isPending, startTransition] = useTransition();
- // Call drift detection on mount (client-side)
- useSafeLayoutEffect(() => {
- if (canUseKeyless) {
- void detectKeylessEnvDriftAction();
- }
- }, []);
-
// Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider
const isNested = Boolean(useClerkNextOptions());
if (isNested) {
@@ -137,12 +134,19 @@ const NextClientClerkProvider = (props: NextClerkProviderProps) => {
);
};
-export const ClientClerkProvider = (props: NextClerkProviderProps & { disableKeyless?: boolean }) => {
- const { children, disableKeyless = false, ...rest } = props;
+export const ClientClerkProvider = (
+ props: NextClerkProviderProps & { disableKeyless?: boolean; disableKeylessDriftDetection?: boolean },
+) => {
+ const { children, disableKeyless = false, disableKeylessDriftDetection = false, ...rest } = props;
const safePublishableKey = mergeNextClerkPropsWithEnv(rest).publishableKey;
if (safePublishableKey || !canUseKeyless || disableKeyless) {
- return {children};
+ return (
+
+ {!disableKeylessDriftDetection && }
+ {children}
+
+ );
}
return (
diff --git a/packages/nextjs/src/app-router/client/keyless-drift-detector.tsx b/packages/nextjs/src/app-router/client/keyless-drift-detector.tsx
new file mode 100644
index 00000000000..cdcf47e6ab4
--- /dev/null
+++ b/packages/nextjs/src/app-router/client/keyless-drift-detector.tsx
@@ -0,0 +1,17 @@
+import { useSelectedLayoutSegments } from 'next/navigation';
+import { useEffect } from 'react';
+
+import { canUseKeyless } from '../../utils/feature-flags';
+
+export function KeylessDriftDetector() {
+ const segments = useSelectedLayoutSegments();
+ const isNotFoundRoute = segments[0]?.startsWith('/_not-found') || false;
+
+ useEffect(() => {
+ if (canUseKeyless && !isNotFoundRoute) {
+ void import('../keyless-actions.js').then(m => m.detectKeylessEnvDriftAction());
+ }
+ }, [isNotFoundRoute]);
+
+ return null;
+}
diff --git a/packages/nextjs/src/app-router/server/ClerkProvider.tsx b/packages/nextjs/src/app-router/server/ClerkProvider.tsx
index 5517416af46..0897044b487 100644
--- a/packages/nextjs/src/app-router/server/ClerkProvider.tsx
+++ b/packages/nextjs/src/app-router/server/ClerkProvider.tsx
@@ -7,8 +7,10 @@ import { PromisifiedAuthProvider } from '../../client-boundary/PromisifiedAuthPr
import { getDynamicAuthData } from '../../server/buildClerkProps';
import type { NextClerkProviderProps } from '../../types';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
+import { onlyTry } from '../../utils/only-try';
import { isNext13 } from '../../utils/sdk-versions';
import { ClientClerkProvider } from '../client/ClerkProvider';
+import { SuspenseWhenCached } from '../suspense-when-cached';
import { getKeylessStatus, KeylessProvider } from './keyless-provider';
import { buildRequestLike, getScriptNonceFromHeader } from './utils';
@@ -65,39 +67,48 @@ export async function ClerkProvider(
...rest,
});
- const { shouldRunAsKeyless, runningWithClaimedKeys } = await getKeylessStatus(propsWithEnvs);
+ const { shouldRunAsKeyless, runningWithClaimedKeys, runningWithDriftedKeys } = await getKeylessStatus(propsWithEnvs);
- let output: ReactNode;
-
- try {
- const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then(
- mod => mod.detectKeylessEnvDrift,
- );
- await detectKeylessEnvDrift();
- } catch {
- // ignore
+ if (runningWithDriftedKeys) {
+ onlyTry(async () => {
+ const detectKeylessEnvDrift = await import('../../server/keyless-telemetry.js').then(
+ mod => mod.detectKeylessEnvDrift,
+ );
+ await detectKeylessEnvDrift();
+ });
}
+ let output: ReactNode;
+
if (shouldRunAsKeyless) {
output = (
-
- {children}
-
+
+
+ {children}
+
+
);
} else {
output = (
-
- {children}
-
+
+ {children}
+
+
);
}
diff --git a/packages/nextjs/src/app-router/server/keyless-provider.tsx b/packages/nextjs/src/app-router/server/keyless-provider.tsx
index 74e450373db..228d652f8bf 100644
--- a/packages/nextjs/src/app-router/server/keyless-provider.tsx
+++ b/packages/nextjs/src/app-router/server/keyless-provider.tsx
@@ -16,19 +16,29 @@ import { deleteKeylessAction } from '../keyless-actions';
export async function getKeylessStatus(
params: Without,
) {
- let [shouldRunAsKeyless, runningWithClaimedKeys, locallyStoredPublishableKey] = [false, false, ''];
+ let [shouldRunAsKeyless, runningWithClaimedKeys, runningWithDriftedKeys, locallyStoredPublishableKey] = [
+ false,
+ false,
+ false,
+ '',
+ ];
if (canUseKeyless) {
locallyStoredPublishableKey = await import('../../server/keyless-node.js')
.then(mod => mod.safeParseClerkFile()?.publishableKey || '')
.catch(() => '');
runningWithClaimedKeys = Boolean(params.publishableKey) && params.publishableKey === locallyStoredPublishableKey;
+ runningWithDriftedKeys =
+ Boolean(params.publishableKey) &&
+ Boolean(locallyStoredPublishableKey) &&
+ params.publishableKey !== locallyStoredPublishableKey;
shouldRunAsKeyless = !params.publishableKey || runningWithClaimedKeys;
}
return {
shouldRunAsKeyless,
runningWithClaimedKeys,
+ runningWithDriftedKeys,
};
}
@@ -59,6 +69,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
nonce={await generateNonce()}
initialState={await generateStatePromise()}
disableKeyless
+ disableKeylessDriftDetection
>
{children}
@@ -77,6 +88,7 @@ export const KeylessProvider = async (props: KeylessProviderProps) => {
})}
nonce={await generateNonce()}
initialState={await generateStatePromise()}
+ disableKeylessDriftDetection
>
{children}
diff --git a/packages/nextjs/src/app-router/suspense-when-cached.tsx b/packages/nextjs/src/app-router/suspense-when-cached.tsx
new file mode 100644
index 00000000000..2c01cb05ae2
--- /dev/null
+++ b/packages/nextjs/src/app-router/suspense-when-cached.tsx
@@ -0,0 +1,30 @@
+import { type PropsWithChildren, Suspense } from 'react';
+import React from 'react';
+
+// eslint-disable-next-line turbo/no-undeclared-env-vars
+const isNextCacheComponents = process.env.__NEXT_CACHE_COMPONENTS;
+
+/**
+ * Wraps the children in a Suspense component if the current environment is a Next.js cache component.
+ * @param children - The children to render.
+ * @param noopWhen - A condition to opt out of wrapping with Suspense.
+ */
+export const SuspenseWhenCached = ({
+ children,
+ noopWhen = false,
+}: PropsWithChildren<{
+ /**
+ * A condition to opt out of wrapping with Suspense.
+ */
+ noopWhen?: boolean;
+}>) => {
+ if (!isNextCacheComponents) {
+ return children;
+ }
+
+ if (noopWhen) {
+ return children;
+ }
+
+ return {children};
+};
diff --git a/packages/nextjs/src/server/keyless-telemetry.ts b/packages/nextjs/src/server/keyless-telemetry.ts
index 4995924255c..842e8941a6a 100644
--- a/packages/nextjs/src/server/keyless-telemetry.ts
+++ b/packages/nextjs/src/server/keyless-telemetry.ts
@@ -6,8 +6,7 @@ import { nodeFsOrThrow, nodePathOrThrow } from './fs/utils';
const EVENT_KEYLESS_ENV_DRIFT_DETECTED = 'KEYLESS_ENV_DRIFT_DETECTED';
const EVENT_SAMPLING_RATE = 1; // 100% sampling rate
-const TELEMETRY_FLAG_FILE = '.clerk/.tmp/telemetry.json';
-
+const TELEMETRY_FLAG_FILE_TEMPLATE = '.clerk/.tmp/telemetry.{pk}.log';
type EventKeylessEnvDriftPayload = {
publicKeyMatch: boolean;
secretKeyMatch: boolean;
@@ -25,9 +24,9 @@ type EventKeylessEnvDriftPayload = {
*
* @returns The absolute path to the telemetry flag file in the project's .clerk/.tmp directory
*/
-function getTelemetryFlagFilePath(): string {
+function getTelemetryFlagFilePath(pk: string): string {
const path = nodePathOrThrow();
- return path.join(process.cwd(), TELEMETRY_FLAG_FILE);
+ return path.join(process.cwd(), TELEMETRY_FLAG_FILE_TEMPLATE.replace('{pk}', pk));
}
/**
@@ -41,26 +40,22 @@ function getTelemetryFlagFilePath(): string {
* the event should be fired), false if the file already exists (meaning the event was
* already fired) or if there was an error creating the file
*/
-function tryMarkTelemetryEventAsFired(): boolean {
+function tryMarkTelemetryEventAsFired(pk: string): boolean {
+ if (!canUseKeyless) {
+ return false;
+ }
try {
- if (canUseKeyless) {
- const { mkdirSync, writeFileSync } = nodeFsOrThrow();
- const path = nodePathOrThrow();
- const flagFilePath = getTelemetryFlagFilePath();
- const flagDirectory = path.dirname(flagFilePath);
+ const { mkdirSync, writeFileSync } = nodeFsOrThrow();
+ const path = nodePathOrThrow();
+ const flagFilePath = getTelemetryFlagFilePath(pk);
+ const flagDirectory = path.dirname(flagFilePath);
- // Ensure the directory exists before attempting to write the file
- mkdirSync(flagDirectory, { recursive: true });
+ // Ensure the directory exists before attempting to write the file
+ mkdirSync(flagDirectory, { recursive: true });
- const flagData = {
- firedAt: new Date().toISOString(),
- event: EVENT_KEYLESS_ENV_DRIFT_DETECTED,
- };
- writeFileSync(flagFilePath, JSON.stringify(flagData, null, 2), { flag: 'wx' });
- return true;
- } else {
- return false;
- }
+ const fileContent = `Content not important. File name is the identifier for the telemetry event.`;
+ writeFileSync(flagFilePath, fileContent, { flag: 'wx' });
+ return true;
} catch (error: unknown) {
if ((error as { code?: string })?.code === 'EEXIST') {
return false;
@@ -96,10 +91,6 @@ export async function detectKeylessEnvDrift(): Promise {
if (!canUseKeyless) {
return;
}
- // Only run on server side
- if (typeof window !== 'undefined') {
- return;
- }
try {
// Dynamically import server-side dependencies to avoid client-side issues
@@ -165,7 +156,7 @@ export async function detectKeylessEnvDrift(): Promise {
secretKeyMatch,
envVarsMissing,
keylessFileHasKeys,
- keylessPublishableKey: keylessFile.publishableKey ?? '',
+ keylessPublishableKey: keylessFile.publishableKey,
envPublishableKey: envPublishableKey ?? '',
};
@@ -178,7 +169,7 @@ export async function detectKeylessEnvDrift(): Promise {
},
});
- const shouldFireEvent = tryMarkTelemetryEventAsFired();
+ const shouldFireEvent = tryMarkTelemetryEventAsFired(keylessFile.publishableKey);
if (shouldFireEvent) {
// Fire drift detected event only if we successfully created the flag