Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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/nextjs-cache-components-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/nextjs': patch
---

Add support for Next.js 16 cache components by improving error detection and providing helpful error messages when `auth()` or `currentUser()` are called inside a `"use cache"` function.
92 changes: 92 additions & 0 deletions packages/nextjs/src/app-router/server/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, expect, it } from 'vitest';

import { isNextjsUseCacheError, isPrerenderingBailout } from '../utils';

describe('isPrerenderingBailout', () => {
it('returns false for non-Error values', () => {
expect(isPrerenderingBailout(null)).toBe(false);
expect(isPrerenderingBailout(undefined)).toBe(false);
expect(isPrerenderingBailout('string')).toBe(false);
expect(isPrerenderingBailout(123)).toBe(false);
expect(isPrerenderingBailout({})).toBe(false);
});

it('returns true for dynamic server usage errors', () => {
const error = new Error('Dynamic server usage: headers');
expect(isPrerenderingBailout(error)).toBe(true);
});

it('returns true for bail out of prerendering errors', () => {
const error = new Error('This page needs to bail out of prerendering');
expect(isPrerenderingBailout(error)).toBe(true);
});

it('returns true for route prerendering bailout errors (Next.js 14.1.1+)', () => {
const error = new Error(
'Route /example needs to bail out of prerendering at this point because it used headers().',
);
expect(isPrerenderingBailout(error)).toBe(true);
});

it('returns true for headers() rejection during prerendering (Next.js 16 cacheComponents)', () => {
const error = new Error(
'During prerendering, `headers()` rejects when the prerender is complete. ' +
'Typically these errors are handled by React but if you move `headers()` to a different context ' +
'by using `setTimeout`, `after`, or similar functions you may observe this error and you should handle it in that context.',
);
expect(isPrerenderingBailout(error)).toBe(true);
});

it('returns false for unrelated errors', () => {
const error = new Error('Some other error');
expect(isPrerenderingBailout(error)).toBe(false);
});
});

describe('isNextjsUseCacheError', () => {
it('returns false for non-Error values', () => {
expect(isNextjsUseCacheError(null)).toBe(false);
expect(isNextjsUseCacheError(undefined)).toBe(false);
expect(isNextjsUseCacheError('string')).toBe(false);
expect(isNextjsUseCacheError(123)).toBe(false);
expect(isNextjsUseCacheError({})).toBe(false);
});

it('returns true for "use cache" errors', () => {
const error = new Error('Route /example used `headers()` inside "use cache"');
expect(isNextjsUseCacheError(error)).toBe(true);
});

it('returns true for cache scope errors', () => {
const error = new Error(
'Accessing Dynamic data sources inside a cache scope is not supported. ' +
'If you need this data inside a cached function use `headers()` outside of the cached function.',
);
expect(isNextjsUseCacheError(error)).toBe(true);
});

it('returns true for dynamic data source cache errors', () => {
const error = new Error('Dynamic data source accessed in cache context');
expect(isNextjsUseCacheError(error)).toBe(true);
});

it('returns false for regular prerendering bailout errors', () => {
const error = new Error('Dynamic server usage: headers');
expect(isNextjsUseCacheError(error)).toBe(false);
});

it('returns false for unrelated errors', () => {
const error = new Error('Some other error');
expect(isNextjsUseCacheError(error)).toBe(false);
});

it('returns true for the exact Next.js 16 error message', () => {
const error = new Error(
'Route /examples/cached-components used `headers()` inside "use cache". ' +
'Accessing Dynamic data sources inside a cache scope is not supported. ' +
'If you need this data inside a cached function use `headers()` outside of the cached function ' +
'and pass the required dynamic data in as an argument.',
);
expect(isNextjsUseCacheError(error)).toBe(true);
});
});
126 changes: 67 additions & 59 deletions packages/nextjs/src/app-router/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { unauthorized } from '../../server/nextErrors';
import type { AuthProtect } from '../../server/protect';
import { createProtect } from '../../server/protect';
import { decryptClerkRequestData } from '../../server/utils';
import { buildRequestLike } from './utils';
import { buildRequestLike, isNextjsUseCacheError, USE_CACHE_ERROR_MESSAGE } from './utils';

/**
* `Auth` object of the currently active user and the `redirectToSignIn()` method.
Expand Down Expand Up @@ -71,68 +71,76 @@ export const auth: AuthFn = (async (options?: AuthOptions) => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('server-only');

const request = await buildRequestLike();

const stepsBasedOnSrcDirectory = async () => {
try {
const isSrcAppDir = await import('../../server/fs/middleware-location.js').then(m => m.hasSrcAppDir());
return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}middleware.(ts|js)`];
} catch {
return [];
}
};
const authObject = await createAsyncGetAuth({
debugLoggerName: 'auth()',
noAuthStatusMessage: authAuthHeaderMissing('auth', await stepsBasedOnSrcDirectory()),
})(request, {
treatPendingAsSignedOut: options?.treatPendingAsSignedOut,
acceptsToken: options?.acceptsToken ?? TokenType.SessionToken,
});

const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl');

const createRedirectForRequest = (...args: Parameters<RedirectFun<never>>) => {
const { returnBackUrl } = args[0] || {};
const clerkRequest = createClerkRequest(request);
const devBrowserToken =
clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) ||
clerkRequest.cookies.get(constants.Cookies.DevBrowser);

const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData);
const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);
return [
createRedirect({
redirectAdapter: redirect,
devBrowserToken: devBrowserToken,
baseUrl: clerkRequest.clerkUrl.toString(),
publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY,
signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL,
signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL,
sessionStatus: authObject.tokenType === TokenType.SessionToken ? authObject.sessionStatus : null,
}),
returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(),
] as const;
};

const redirectToSignIn: RedirectFun<never> = (opts = {}) => {
const [r, returnBackUrl] = createRedirectForRequest(opts);
return r.redirectToSignIn({
returnBackUrl,
try {
const request = await buildRequestLike();

const stepsBasedOnSrcDirectory = async () => {
try {
const isSrcAppDir = await import('../../server/fs/middleware-location.js').then(m => m.hasSrcAppDir());
return [`Your Middleware exists at ./${isSrcAppDir ? 'src/' : ''}middleware.(ts|js)`];
} catch {
return [];
}
};
const authObject = await createAsyncGetAuth({
debugLoggerName: 'auth()',
noAuthStatusMessage: authAuthHeaderMissing('auth', await stepsBasedOnSrcDirectory()),
})(request, {
treatPendingAsSignedOut: options?.treatPendingAsSignedOut,
acceptsToken: options?.acceptsToken ?? TokenType.SessionToken,
});
};

const redirectToSignUp: RedirectFun<never> = (opts = {}) => {
const [r, returnBackUrl] = createRedirectForRequest(opts);
return r.redirectToSignUp({
returnBackUrl,
});
};
const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl');

const createRedirectForRequest = (...args: Parameters<RedirectFun<never>>) => {
const { returnBackUrl } = args[0] || {};
const clerkRequest = createClerkRequest(request);
const devBrowserToken =
clerkRequest.clerkUrl.searchParams.get(constants.QueryParameters.DevBrowser) ||
clerkRequest.cookies.get(constants.Cookies.DevBrowser);

const encryptedRequestData = getHeader(request, constants.Headers.ClerkRequestData);
const decryptedRequestData = decryptClerkRequestData(encryptedRequestData);
return [
createRedirect({
redirectAdapter: redirect,
devBrowserToken: devBrowserToken,
baseUrl: clerkRequest.clerkUrl.toString(),
publishableKey: decryptedRequestData.publishableKey || PUBLISHABLE_KEY,
signInUrl: decryptedRequestData.signInUrl || SIGN_IN_URL,
signUpUrl: decryptedRequestData.signUpUrl || SIGN_UP_URL,
sessionStatus: authObject.tokenType === TokenType.SessionToken ? authObject.sessionStatus : null,
}),
returnBackUrl === null ? '' : returnBackUrl || clerkUrl?.toString(),
] as const;
};

const redirectToSignIn: RedirectFun<never> = (opts = {}) => {
const [r, returnBackUrl] = createRedirectForRequest(opts);
return r.redirectToSignIn({
returnBackUrl,
});
};

const redirectToSignUp: RedirectFun<never> = (opts = {}) => {
const [r, returnBackUrl] = createRedirectForRequest(opts);
return r.redirectToSignUp({
returnBackUrl,
});
};

if (authObject.tokenType === TokenType.SessionToken) {
return Object.assign(authObject, { redirectToSignIn, redirectToSignUp });
}

if (authObject.tokenType === TokenType.SessionToken) {
return Object.assign(authObject, { redirectToSignIn, redirectToSignUp });
return authObject;
} catch (e: any) {
// Catch "use cache" errors that bubble up from Next.js cache boundary
if (isNextjsUseCacheError(e)) {
throw new Error(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`);
}
throw e;
}

return authObject;
}) as AuthFn;

auth.protect = async (...args: any[]) => {
Expand Down
19 changes: 14 additions & 5 deletions packages/nextjs/src/app-router/server/currentUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { PendingSessionOptions } from '@clerk/shared/types';

import { clerkClient } from '../../server/clerkClient';
import { auth } from './auth';
import { isNextjsUseCacheError, USE_CACHE_ERROR_MESSAGE } from './utils';

type CurrentUserOptions = PendingSessionOptions;

Expand Down Expand Up @@ -31,10 +32,18 @@ export async function currentUser(opts?: CurrentUserOptions): Promise<User | nul
// eslint-disable-next-line @typescript-eslint/no-require-imports
require('server-only');

const { userId } = await auth({ treatPendingAsSignedOut: opts?.treatPendingAsSignedOut });
if (!userId) {
return null;
}
try {
const { userId } = await auth({ treatPendingAsSignedOut: opts?.treatPendingAsSignedOut });
if (!userId) {
return null;
}

return (await clerkClient()).users.getUser(userId);
return (await clerkClient()).users.getUser(userId);
} catch (e: any) {
// Catch "use cache" errors that bubble up from Next.js cache boundary
if (isNextjsUseCacheError(e)) {
throw new Error(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`);
}
throw e;
}
}
56 changes: 55 additions & 1 deletion packages/nextjs/src/app-router/server/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { NextRequest } from 'next/server';

// Pre-compiled regex patterns for cache error detection
const USE_CACHE_PATTERN = /use cache|cache scope/i;
const DYNAMIC_CACHE_PATTERN = /dynamic data source/i;

export const isPrerenderingBailout = (e: unknown) => {
if (!(e instanceof Error) || !('message' in e)) {
return false;
Expand All @@ -11,14 +15,59 @@ export const isPrerenderingBailout = (e: unknown) => {
const dynamicServerUsage = lowerCaseInput.includes('dynamic server usage');
const bailOutPrerendering = lowerCaseInput.includes('this page needs to bail out of prerendering');

// Next.js 16+ with cacheComponents: headers() rejects during prerendering
// Error: "During prerendering, `headers()` rejects when the prerender is complete"
const headersRejectsDuringPrerendering = lowerCaseInput.includes('during prerendering');

// note: new error message syntax introduced in [email protected]
// but we still want to support older versions.
// https://github.com/vercel/next.js/pull/61332 (dynamic-rendering.ts:153)
const routeRegex = /Route .*? needs to bail out of prerendering at this point because it used .*?./;

return routeRegex.test(message) || dynamicServerUsage || bailOutPrerendering;
return routeRegex.test(message) || dynamicServerUsage || bailOutPrerendering || headersRejectsDuringPrerendering;
};

/**
* Detects if the error is from using dynamic APIs inside a "use cache" component.
* Next.js 16+ throws specific errors when headers(), cookies(), or other dynamic
* APIs are accessed inside a cache scope.
*/
export const isNextjsUseCacheError = (e: unknown): boolean => {
if (!(e instanceof Error)) {
return false;
}

const { message } = e;

// Check for "use cache" or "cache scope" mentions
if (USE_CACHE_PATTERN.test(message)) {
return true;
}

// Check compound pattern: requires both "dynamic data source" AND "cache"
return DYNAMIC_CACHE_PATTERN.test(message) && message.toLowerCase().includes('cache');
};

/**
* Error message for when auth()/currentUser() is called inside a "use cache" function.
* Exported so it can be reused in auth.ts and currentUser.ts for consistent messaging.
*/
export const USE_CACHE_ERROR_MESSAGE =
`Clerk: auth() and currentUser() cannot be called inside a "use cache" function. ` +
`These functions access \`headers()\` internally, which is a dynamic API not allowed in cached contexts.\n\n` +
`To fix this, call auth() outside the cached function and pass the userId as an argument:\n\n` +
Copy link
Member

Choose a reason for hiding this comment

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

NIT: userId is by far the most likely here, but not the only possible thing one might want to pass in.

Perhaps something like this?

Suggested change
`To fix this, call auth() outside the cached function and pass the userId as an argument:\n\n` +
`To fix this, call auth() outside the cached function and pass the values you need as arguments:\n\n` +

` import { auth, clerkClient } from '@clerk/nextjs/server';\n\n` +
` async function getCachedUser(userId: string) {\n` +
` "use cache";\n` +
` const client = await clerkClient();\n` +
` return client.users.getUser(userId);\n` +
` }\n\n` +
` // In your component/page:\n` +
` const { userId } = await auth();\n` +
` if (userId) {\n` +
` const user = await getCachedUser(userId);\n` +
` }`;

export async function buildRequestLike(): Promise<NextRequest> {
try {
// Dynamically import next/headers, otherwise Next12 apps will break
Expand All @@ -33,6 +82,11 @@ export async function buildRequestLike(): Promise<NextRequest> {
throw e;
}

// Provide a helpful error message for "use cache" components
if (e && isNextjsUseCacheError(e)) {
throw new Error(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`);
}

throw new Error(
`Clerk: auth(), currentUser() and clerkClient(), are only supported in App Router (/app directory).\nIf you're using /pages, try getAuth() instead.\nOriginal error: ${e}`,
);
Expand Down
6 changes: 5 additions & 1 deletion packages/nextjs/src/server/clerkClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { constants } from '@clerk/backend/internal';

import { buildRequestLike, isPrerenderingBailout } from '../app-router/server/utils';
import { buildRequestLike, isNextjsUseCacheError, isPrerenderingBailout } from '../app-router/server/utils';
import { createClerkClientWithOptions } from './createClerkClient';
import { getHeader } from './headers-utils';
import { clerkMiddlewareRequestDataStorage } from './middleware-storage';
Expand All @@ -21,6 +21,10 @@ const clerkClient = async () => {
if (err && isPrerenderingBailout(err)) {
throw err;
}
// Re-throw "use cache" errors with the helpful message from buildRequestLike
if (err && isNextjsUseCacheError(err)) {
throw err;
}
}

// Fallbacks between options from middleware runtime and `NextRequest` from application server
Expand Down
Loading