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/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 { if (!(e instanceof Error) || !('message' in e)) { return false; @@ -11,14 +19,56 @@ export const isPrerenderingBailout = (e: unknown) => { const dynamicServerUsage = lowerCaseInput.includes('dynamic server usage'); const bailOutPrerendering = lowerCaseInput.includes('this page needs to bail out of 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 .*?./; + // 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'); - return routeRegex.test(message) || dynamicServerUsage || bailOutPrerendering; + return ( + ROUTE_BAILOUT_PATTERN.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` + + ` 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 @@ -33,6 +83,11 @@ export async function buildRequestLike(): Promise { 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}`, ); 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