From f5da1c65d7d9a16ce346bcdc45996a20fc3dd938 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 13 Jan 2026 20:41:45 -0600 Subject: [PATCH 1/4] feat(nextjs): Add support for Next.js 16 cache components - Add `isNextjsUseCacheError()` helper to detect "use cache" context errors - Update `isPrerenderingBailout()` to detect cache component prerendering errors - Update `buildRequestLike()` to provide helpful error message with code example when auth() is called inside a "use cache" function - Update `clerkClient()` to re-throw "use cache" errors - Add unit tests for error detection helpers --- .changeset/nextjs-cache-components-support.md | 5 + .../app-router/server/__tests__/utils.test.ts | 92 +++++++++++++++++++ .../nextjs/src/app-router/server/utils.ts | 52 ++++++++++- packages/nextjs/src/server/clerkClient.ts | 6 +- 4 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 .changeset/nextjs-cache-components-support.md create mode 100644 packages/nextjs/src/app-router/server/__tests__/utils.test.ts diff --git a/.changeset/nextjs-cache-components-support.md b/.changeset/nextjs-cache-components-support.md new file mode 100644 index 00000000000..b8938cffea7 --- /dev/null +++ b/.changeset/nextjs-cache-components-support.md @@ -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. diff --git a/packages/nextjs/src/app-router/server/__tests__/utils.test.ts b/packages/nextjs/src/app-router/server/__tests__/utils.test.ts new file mode 100644 index 00000000000..b21f134d151 --- /dev/null +++ b/packages/nextjs/src/app-router/server/__tests__/utils.test.ts @@ -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); + }); +}); diff --git a/packages/nextjs/src/app-router/server/utils.ts b/packages/nextjs/src/app-router/server/utils.ts index 1c6e24a38f4..7722fc7cafb 100644 --- a/packages/nextjs/src/app-router/server/utils.ts +++ b/packages/nextjs/src/app-router/server/utils.ts @@ -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; @@ -11,12 +15,37 @@ 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 next@14.1.1-canary.21 // 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'); }; export async function buildRequestLike(): Promise { @@ -33,6 +62,27 @@ export async function buildRequestLike(): Promise { throw e; } + // Provide a helpful error message for "use cache" components + if (e && isNextjsUseCacheError(e)) { + throw new Error( + `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` + + ` 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` + + ` }\n\n` + + `Original 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}`, ); diff --git a/packages/nextjs/src/server/clerkClient.ts b/packages/nextjs/src/server/clerkClient.ts index 907d7db1e66..2acc8d74f10 100644 --- a/packages/nextjs/src/server/clerkClient.ts +++ b/packages/nextjs/src/server/clerkClient.ts @@ -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'; @@ -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 From 5a1d4c3587e13fa1220add4d0b32c1c10299adfa Mon Sep 17 00:00:00 2001 From: Jacek Date: Wed, 14 Jan 2026 12:07:54 -0600 Subject: [PATCH 2/4] fix(nextjs): catch use cache errors at auth/currentUser level Wrap auth() and currentUser() in try-catch to catch "use cache" errors that bubble up from the Next.js cache boundary. The original implementation only caught errors inside buildRequestLike(), but Next.js throws these errors at a higher level. --- packages/nextjs/src/app-router/server/auth.ts | 126 ++++++++++-------- .../src/app-router/server/currentUser.ts | 19 ++- .../nextjs/src/app-router/server/utils.ts | 38 +++--- 3 files changed, 102 insertions(+), 81 deletions(-) diff --git a/packages/nextjs/src/app-router/server/auth.ts b/packages/nextjs/src/app-router/server/auth.ts index c8e0549e82d..b514fe0ea4a 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -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. @@ -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>) => { - 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 = (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 = (opts = {}) => { - const [r, returnBackUrl] = createRedirectForRequest(opts); - return r.redirectToSignUp({ - returnBackUrl, - }); - }; + const clerkUrl = getAuthKeyFromRequest(request, 'ClerkUrl'); + + const createRedirectForRequest = (...args: Parameters>) => { + 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 = (opts = {}) => { + const [r, returnBackUrl] = createRedirectForRequest(opts); + return r.redirectToSignIn({ + returnBackUrl, + }); + }; + + const redirectToSignUp: RedirectFun = (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[]) => { diff --git a/packages/nextjs/src/app-router/server/currentUser.ts b/packages/nextjs/src/app-router/server/currentUser.ts index 99c90113d04..697bc6a7265 100644 --- a/packages/nextjs/src/app-router/server/currentUser.ts +++ b/packages/nextjs/src/app-router/server/currentUser.ts @@ -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; @@ -31,10 +32,18 @@ export async function currentUser(opts?: CurrentUserOptions): Promise { 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` + + ` 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 { try { // Dynamically import next/headers, otherwise Next12 apps will break @@ -64,23 +84,7 @@ export async function buildRequestLike(): Promise { // Provide a helpful error message for "use cache" components if (e && isNextjsUseCacheError(e)) { - throw new Error( - `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` + - ` 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` + - ` }\n\n` + - `Original error: ${e.message}`, - ); + throw new Error(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`); } throw new Error( From 16cb41b49bde1040a8895e7bddb85fb340d0eec4 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 15 Jan 2026 11:41:03 -0600 Subject: [PATCH 3/4] refactor: hoist route bailout regex pattern to module level --- packages/nextjs/src/app-router/server/utils.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/nextjs/src/app-router/server/utils.ts b/packages/nextjs/src/app-router/server/utils.ts index ad2bc4d2f1c..81b8dee0070 100644 --- a/packages/nextjs/src/app-router/server/utils.ts +++ b/packages/nextjs/src/app-router/server/utils.ts @@ -1,8 +1,12 @@ import { NextRequest } from 'next/server'; -// Pre-compiled regex patterns for cache error detection +// Pre-compiled regex patterns for error detection const USE_CACHE_PATTERN = /use cache|cache scope/i; const DYNAMIC_CACHE_PATTERN = /dynamic data source/i; +// note: new error message syntax introduced in next@14.1.1-canary.21 +// but we still want to support older versions. +// https://github.com/vercel/next.js/pull/61332 (dynamic-rendering.ts:153) +const ROUTE_BAILOUT_PATTERN = /Route .*? needs to bail out of prerendering at this point because it used .*?./; export const isPrerenderingBailout = (e: unknown) => { if (!(e instanceof Error) || !('message' in e)) { @@ -19,12 +23,7 @@ export const isPrerenderingBailout = (e: unknown) => { // Error: "During prerendering, `headers()` rejects when the prerender is complete" const headersRejectsDuringPrerendering = lowerCaseInput.includes('during prerendering'); - // note: new error message syntax introduced in next@14.1.1-canary.21 - // 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 || headersRejectsDuringPrerendering; + return ROUTE_BAILOUT_PATTERN.test(message) || dynamicServerUsage || bailOutPrerendering || headersRejectsDuringPrerendering; }; /** From 376fd84311b86f2eb335c47cbb49e5d289d29116 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 15 Jan 2026 11:45:31 -0600 Subject: [PATCH 4/4] style: format utils.ts --- packages/nextjs/src/app-router/server/utils.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/nextjs/src/app-router/server/utils.ts b/packages/nextjs/src/app-router/server/utils.ts index 81b8dee0070..8072ebd618a 100644 --- a/packages/nextjs/src/app-router/server/utils.ts +++ b/packages/nextjs/src/app-router/server/utils.ts @@ -23,7 +23,9 @@ export const isPrerenderingBailout = (e: unknown) => { // Error: "During prerendering, `headers()` rejects when the prerender is complete" const headersRejectsDuringPrerendering = lowerCaseInput.includes('during prerendering'); - return ROUTE_BAILOUT_PATTERN.test(message) || dynamicServerUsage || bailOutPrerendering || headersRejectsDuringPrerendering; + return ( + ROUTE_BAILOUT_PATTERN.test(message) || dynamicServerUsage || bailOutPrerendering || headersRejectsDuringPrerendering + ); }; /**