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/integration/templates/next-cache-components/src/app/api/use-cache-error-trigger/route.ts b/integration/templates/next-cache-components/src/app/api/use-cache-error-trigger/route.ts new file mode 100644 index 00000000000..e4e7cb95f03 --- /dev/null +++ b/integration/templates/next-cache-components/src/app/api/use-cache-error-trigger/route.ts @@ -0,0 +1,19 @@ +import { auth } from '@clerk/nextjs/server'; + +// This function deliberately calls auth() inside "use cache" to trigger the error +async function getCachedAuthData() { + 'use cache'; + // This WILL throw an error because auth() uses headers() internally + const { userId } = await auth(); + return { userId }; +} + +export async function GET() { + try { + const data = await getCachedAuthData(); + return Response.json(data); + } catch (e: any) { + // Return the error message so we can verify it in tests + return Response.json({ error: e.message }, { status: 500 }); + } +} diff --git a/integration/templates/next-cache-components/src/app/current-user-cache-correct/page.tsx b/integration/templates/next-cache-components/src/app/current-user-cache-correct/page.tsx new file mode 100644 index 00000000000..a7e4f82f11a --- /dev/null +++ b/integration/templates/next-cache-components/src/app/current-user-cache-correct/page.tsx @@ -0,0 +1,93 @@ +import { Suspense } from 'react'; +import { currentUser, clerkClient } from '@clerk/nextjs/server'; + +// Simulated cached operation that fetches additional user data +async function getCachedUserProfile(userId: string) { + 'use cache'; + // This is the CORRECT pattern: + // - currentUser() is called OUTSIDE the cache function + // - Only the userId is passed into the cache function + // - The cache function uses clerkClient() which is allowed in cache contexts + const client = await clerkClient(); + const user = await client.users.getUser(userId); + + return { + userId, + cachedAt: new Date().toISOString(), + profile: { + fullName: [user.firstName, user.lastName].filter(Boolean).join(' ') || 'Unknown', + emailCount: user.emailAddresses?.length ?? 0, + }, + }; +} + +async function CurrentUserCacheContent() { + // Step 1: Call currentUser() OUTSIDE the cache function + const user = await currentUser(); + + if (!user) { + return ( + <> +

Please sign in to test the caching pattern with currentUser().

+
Not signed in
+ + ); + } + + // Step 2: Pass userId INTO the cache function + const cachedProfile = await getCachedUserProfile(user.id); + + return ( + <> +

+ This demonstrates the correct way to use "use cache" with currentUser(): +

+
    +
  1. + Call currentUser() outside the cache function +
  2. +
  3. + Pass the userId into the cache function +
  4. +
  5. + Use clerkClient() inside the cache function (allowed) +
  6. +
+ +
+

Cached Profile Data:

+
{JSON.stringify(cachedProfile, null, 2)}
+
+ +
{user.id}
+ +
+        {`
+// Correct pattern:
+const user = await currentUser();  // Outside cache
+if (user) {
+  const profile = await getCachedProfile(user.id);  // Pass userId in
+}
+
+async function getCachedProfile(userId: string) {
+  'use cache';
+  const client = await clerkClient();
+  return client.users.getUser(userId);
+}
+        `}
+      
+ + ); +} + +export default function CurrentUserCacheCorrectPage() { + return ( +
+

currentUser() with "use cache" Correct Pattern

+ + Loading...}> + + +
+ ); +} diff --git a/integration/templates/next-cache-components/src/app/current-user-server-component/page.tsx b/integration/templates/next-cache-components/src/app/current-user-server-component/page.tsx new file mode 100644 index 00000000000..54682ee1d74 --- /dev/null +++ b/integration/templates/next-cache-components/src/app/current-user-server-component/page.tsx @@ -0,0 +1,43 @@ +import { Suspense } from 'react'; +import { currentUser } from '@clerk/nextjs/server'; + +async function CurrentUserContent() { + const user = await currentUser(); + + return ( + <> +
+

Current User Result:

+
+          {JSON.stringify(
+            {
+              id: user?.id ?? null,
+              firstName: user?.firstName ?? null,
+              lastName: user?.lastName ?? null,
+              primaryEmailAddress: user?.primaryEmailAddress?.emailAddress ?? null,
+              isSignedIn: !!user,
+            },
+            null,
+            2,
+          )}
+        
+
+ +
{user?.id ?? 'Not signed in'}
+
{user?.primaryEmailAddress?.emailAddress ?? 'No email'}
+ + ); +} + +export default function CurrentUserServerComponentPage() { + return ( +
+

currentUser() in Server Component

+

This page tests using currentUser() in a standard React Server Component.

+ + Loading user...}> + + +
+ ); +} diff --git a/integration/templates/next-cache-components/src/app/page.tsx b/integration/templates/next-cache-components/src/app/page.tsx index d76a0666b61..5d863924bba 100644 --- a/integration/templates/next-cache-components/src/app/page.tsx +++ b/integration/templates/next-cache-components/src/app/page.tsx @@ -12,6 +12,9 @@ export default function Home() {
  • auth() in Server Component
  • +
  • + currentUser() in Server Component +
  • auth() in Server Action
  • @@ -19,10 +22,16 @@ export default function Home() { auth() in API Route
  • - use cache with auth() (should error) + use cache with auth() (documentation) +
  • +
  • + use cache error trigger (actual error) +
  • +
  • + "use cache" correct pattern (auth)
  • - "use cache" correct pattern + "use cache" correct pattern (currentUser)
  • PPR with auth() diff --git a/integration/templates/next-cache-components/src/app/use-cache-error-trigger/page.tsx b/integration/templates/next-cache-components/src/app/use-cache-error-trigger/page.tsx new file mode 100644 index 00000000000..2acba59609f --- /dev/null +++ b/integration/templates/next-cache-components/src/app/use-cache-error-trigger/page.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +export default function UseCacheErrorTriggerPage() { + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const triggerError = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch('/api/use-cache-error-trigger'); + const data = await response.json(); + if (data.error) { + setError(data.error); + } + } catch (e: any) { + setError(e.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + triggerError(); + }, []); + + return ( +
    +

    "use cache" Error Trigger

    +

    This page triggers an actual error by calling auth() inside a "use cache" function.

    + + {loading &&
    Loading...
    } + + {error && ( +
    +

    Error Caught:

    +
    {error}
    +
    + )} + + +
    + ); +} diff --git a/integration/tests/cache-components.test.ts b/integration/tests/cache-components.test.ts index 356b030b328..a1013250dbf 100644 --- a/integration/tests/cache-components.test.ts +++ b/integration/tests/cache-components.test.ts @@ -27,6 +27,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: await u.page.goToRelative('/'); await expect(u.page.getByText('Next.js Cache Components Test App')).toBeVisible(); await expect(u.page.getByRole('link', { name: 'auth() in Server Component' })).toBeVisible(); + await expect(u.page.getByRole('link', { name: 'currentUser() in Server Component' })).toBeVisible(); + await expect(u.page.getByRole('link', { name: '"use cache" correct pattern (auth)' })).toBeVisible(); + await expect(u.page.getByRole('link', { name: '"use cache" correct pattern (currentUser)' })).toBeVisible(); }); test('auth() in server component works when signed out', async ({ page, context }) => { @@ -58,6 +61,41 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: expect(userId).toMatch(/^user_/); }); + test('currentUser() in server component works when signed out', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/current-user-server-component'); + await expect(u.page.getByText('currentUser() in Server Component')).toBeVisible(); + await expect(u.page.getByTestId('current-user-id')).toContainText('Not signed in'); + }); + + test('currentUser() in server component works when signed in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Sign in first + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + // Navigate to server component page + await u.page.goToRelative('/current-user-server-component'); + await expect(u.page.getByText('currentUser() in Server Component')).toBeVisible(); + + // Should show user ID (starts with 'user_') + const userIdElement = u.page.getByTestId('current-user-id'); + await expect(userIdElement).toBeVisible(); + const userId = await userIdElement.textContent(); + expect(userId).toMatch(/^user_/); + + // Should also show the email + const emailElement = u.page.getByTestId('current-user-email'); + await expect(emailElement).toBeVisible(); + const email = await emailElement.textContent(); + expect(email).toContain('@'); + }); + test('auth() in server action works', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); @@ -102,7 +140,18 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: expect(data.isSignedIn).toBe(true); }); - test('"use cache" correct pattern works', async ({ page, context }) => { + test('"use cache" correct pattern with auth() works when signed out', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Navigate to correct pattern page without signing in + await u.page.goToRelative('/use-cache-correct'); + await expect(u.page.getByText('"use cache" Correct Pattern')).toBeVisible(); + + // Should show signed out message + await expect(u.page.getByTestId('signed-out')).toBeVisible(); + }); + + test('"use cache" correct pattern with auth() works when signed in', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); // Sign in first @@ -124,6 +173,45 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: expect(dataText).toContain('userId'); }); + test('"use cache" correct pattern with currentUser() works when signed out', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Navigate to correct pattern page without signing in + await u.page.goToRelative('/current-user-cache-correct'); + await expect(u.page.getByText('currentUser() with "use cache" Correct Pattern')).toBeVisible(); + + // Should show signed out message + await expect(u.page.getByTestId('signed-out')).toBeVisible(); + }); + + test('"use cache" correct pattern with currentUser() works when signed in', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Sign in first + await u.po.signIn.goTo(); + await u.po.signIn.signInWithEmailAndInstantPassword({ + email: fakeUser.email, + password: fakeUser.password, + }); + await u.po.expect.toBeSignedIn(); + + // Navigate to correct pattern page + await u.page.goToRelative('/current-user-cache-correct'); + await expect(u.page.getByText('currentUser() with "use cache" Correct Pattern')).toBeVisible(); + + // Should show cached profile with user ID + const cachedProfile = u.page.getByTestId('cached-profile'); + await expect(cachedProfile).toBeVisible(); + const profileText = await cachedProfile.textContent(); + expect(profileText).toContain('userId'); + + // Should also show the user ID + const userIdElement = u.page.getByTestId('current-user-id'); + await expect(userIdElement).toBeVisible(); + const userId = await userIdElement.textContent(); + expect(userId).toMatch(/^user_/); + }); + test('"use cache" error documentation page loads', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/use-cache-error'); @@ -131,7 +219,39 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: await expect(u.page.getByTestId('expected-error')).toBeVisible(); }); - test('PPR with auth() renders correctly', async ({ page, context }) => { + test('auth() inside "use cache" shows helpful Clerk error message', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Navigate to the error trigger page + await u.page.goToRelative('/use-cache-error-trigger'); + await expect(u.page.getByText('"use cache" Error Trigger')).toBeVisible(); + + // Wait for the error to be displayed + const errorMessage = u.page.getByTestId('error-message'); + await expect(errorMessage).toBeVisible({ timeout: 10000 }); + + // Verify the error contains our custom Clerk error message + const errorText = await errorMessage.textContent(); + expect(errorText).toContain('Clerk:'); + expect(errorText).toContain('auth() and currentUser() cannot be called inside a "use cache" function'); + expect(errorText).toContain('headers()'); + }); + + test('PPR with auth() renders correctly when signed out', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + // Navigate to PPR page without signing in + await u.page.goToRelative('/ppr-auth'); + await expect(u.page.getByText('PPR with auth()')).toBeVisible(); + + // Static content should be visible (pre-rendered shell) + await expect(u.page.getByTestId('static-content')).toBeVisible(); + + // Dynamic content should stream in even when signed out + await expect(u.page.getByTestId('dynamic-content')).toBeVisible(); + }); + + test('PPR with auth() renders correctly when signed in', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); // Sign in first 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..170dbc590fe --- /dev/null +++ b/packages/nextjs/src/app-router/server/__tests__/utils.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; + +import { ClerkUseCacheError, isClerkUseCacheError, 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('ClerkUseCacheError', () => { + it('is recognized by isClerkUseCacheError', () => { + const error = new ClerkUseCacheError('Test message'); + expect(isClerkUseCacheError(error)).toBe(true); + }); + + it('preserves original error', () => { + const original = new Error('Original'); + const error = new ClerkUseCacheError('Wrapped', original); + expect(error.originalError).toBe(original); + }); + + it('has correct name', () => { + const error = new ClerkUseCacheError('Test'); + expect(error.name).toBe('ClerkUseCacheError'); + }); +}); + +describe('isClerkUseCacheError', () => { + it('returns false for regular errors', () => { + expect(isClerkUseCacheError(new Error('test'))).toBe(false); + }); + + it('returns false for non-Error values', () => { + expect(isClerkUseCacheError(null)).toBe(false); + expect(isClerkUseCacheError('string')).toBe(false); + expect(isClerkUseCacheError({})).toBe(false); + }); + + it('returns true for ClerkUseCacheError', () => { + expect(isClerkUseCacheError(new ClerkUseCacheError('test'))).toBe(true); + }); +}); + +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 with double quotes', () => { + const error = new Error('Route /example used `headers()` inside "use cache"'); + expect(isNextjsUseCacheError(error)).toBe(true); + }); + + it("returns true for 'use cache' errors with single quotes", () => { + const error = new Error("Route /example used `cookies()` 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 false for generic "cache" mentions without specific patterns', () => { + // This should NOT match to reduce false positives - requires "cache scope" not just "cache" + const error = new Error('Dynamic data source accessed in cache context'); + expect(isNextjsUseCacheError(error)).toBe(false); + }); + + 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 af7af1d44a4..f1bae2c325e 100644 --- a/packages/nextjs/src/app-router/server/auth.ts +++ b/packages/nextjs/src/app-router/server/auth.ts @@ -12,7 +12,13 @@ import type { AuthProtect } from '../../server/protect'; import { createProtect } from '../../server/protect'; import { decryptClerkRequestData } from '../../server/utils'; import { middlewareFileReference } from '../../utils/sdk-versions'; -import { buildRequestLike } from './utils'; +import { + buildRequestLike, + ClerkUseCacheError, + isClerkUseCacheError, + isNextjsUseCacheError, + USE_CACHE_ERROR_MESSAGE, +} from './utils'; /** * `Auth` object of the currently active user and the `redirectToSignIn()` method. @@ -72,73 +78,83 @@ 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()); - const fileName = - middlewareFileReference === 'middleware or proxy' - ? 'middleware.(ts|js) or proxy.(ts|js)' - : 'middleware.(ts|js)'; - return [`Your ${middlewareFileReference} file exists at ./${isSrcAppDir ? 'src/' : ''}${fileName}`]; - } catch { - return []; - } - }; - const authObject = await createAsyncGetAuth({ - debugLoggerName: 'auth()', - noAuthStatusMessage: authAuthHeaderMissing('auth', await stepsBasedOnSrcDirectory(), middlewareFileReference), - })(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, - isSatellite: decryptedRequestData.isSatellite, - }), - 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()); + const fileName = + middlewareFileReference === 'middleware or proxy' + ? 'middleware.(ts|js) or proxy.(ts|js)' + : 'middleware.(ts|js)'; + return [`Your ${middlewareFileReference} file exists at ./${isSrcAppDir ? 'src/' : ''}${fileName}`]; + } catch { + return []; + } + }; + const authObject = await createAsyncGetAuth({ + debugLoggerName: 'auth()', + noAuthStatusMessage: authAuthHeaderMissing('auth', await stepsBasedOnSrcDirectory(), middlewareFileReference), + })(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, + isSatellite: decryptedRequestData.isSatellite, + }), + 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) { + if (isClerkUseCacheError(e)) { + throw e; + } + if (isNextjsUseCacheError(e)) { + throw new ClerkUseCacheError(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`, e); + } + 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..02ac8fabfb6 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 { ClerkUseCacheError, isClerkUseCacheError, isNextjsUseCacheError, USE_CACHE_ERROR_MESSAGE } from './utils'; type CurrentUserOptions = PendingSessionOptions; @@ -31,10 +32,20 @@ export async function currentUser(opts?: CurrentUserOptions): Promise { + return e instanceof Error && CLERK_USE_CACHE_MARKER in e; +}; + +// Patterns for Next.js "use cache" errors - tightened to reduce false positives +// Matches both "use cache" (double quotes) and 'use cache' (single quotes) +const USE_CACHE_WITH_DYNAMIC_API_PATTERN = + /inside\s+["']use cache["']|["']use cache["'].*(?:headers|cookies)|(?:headers|cookies).*["']use cache["']/i; +const CACHE_SCOPE_PATTERN = /cache scope/i; +const DYNAMIC_DATA_SOURCE_PATTERN = /dynamic data source/i; +// https://github.com/vercel/next.js/pull/61332 +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)) { return false; } const { message } = e; - const lowerCaseInput = message.toLowerCase(); - 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 .*?./; + return ( + ROUTE_BAILOUT_PATTERN.test(message) || + lowerCaseInput.includes('dynamic server usage') || + lowerCaseInput.includes('this page needs to bail out of prerendering') || + lowerCaseInput.includes('during prerendering') + ); +}; + +/** + * Detects Next.js errors from using dynamic APIs (headers/cookies) inside "use cache". + */ +export const isNextjsUseCacheError = (e: unknown): boolean => { + if (!(e instanceof Error)) { + return false; + } + + const { message } = e; + + // "use cache" with dynamic API context (e.g., 'used `headers()` inside "use cache"') + if (USE_CACHE_WITH_DYNAMIC_API_PATTERN.test(message)) { + return true; + } - return routeRegex.test(message) || dynamicServerUsage || bailOutPrerendering; + // "cache scope" with dynamic data source (e.g., 'Dynamic data sources inside a cache scope') + if (CACHE_SCOPE_PATTERN.test(message) && DYNAMIC_DATA_SOURCE_PATTERN.test(message)) { + return true; + } + + return false; }; +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 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 { try { - // Dynamically import next/headers, otherwise Next12 apps will break - // @ts-expect-error: Cannot find module 'next/headers' or its corresponding type declarations.ts(2307) + // @ts-expect-error - Dynamically import to avoid breaking Next 12 apps const { headers } = await import('next/headers'); const resolvedHeaders = await headers(); return new NextRequest('https://placeholder.com', { headers: resolvedHeaders }); } catch (e: any) { - // rethrow the error when react throws a prerendering bailout // https://nextjs.org/docs/messages/ppr-caught-error if (e && isPrerenderingBailout(e)) { throw e; } + if (e && isNextjsUseCacheError(e)) { + throw new ClerkUseCacheError(`${USE_CACHE_ERROR_MESSAGE}\n\nOriginal error: ${e.message}`, e); + } + 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..eb70784b69d 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, isClerkUseCacheError, isPrerenderingBailout } from '../app-router/server/utils'; import { createClerkClientWithOptions } from './createClerkClient'; import { getHeader } from './headers-utils'; import { clerkMiddlewareRequestDataStorage } from './middleware-storage'; @@ -21,6 +21,9 @@ const clerkClient = async () => { if (err && isPrerenderingBailout(err)) { throw err; } + if (err && isClerkUseCacheError(err)) { + throw err; + } } // Fallbacks between options from middleware runtime and `NextRequest` from application server