diff --git a/.changeset/shiny-numbers-walk.md b/.changeset/shiny-numbers-walk.md
new file mode 100644
index 0000000000..3ffc12abb8
--- /dev/null
+++ b/.changeset/shiny-numbers-walk.md
@@ -0,0 +1,6 @@
+---
+"@clerk/nextjs": major
+---
+
+Support `unstable_rethrow` inside `clerkMiddleware`.
+We changed the errors thrown by `protect()` inside `clerkMiddleware` in order for `unstable_rethrow` to recognise them and rethrow them.
diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts
index eee5510608..2db94271d5 100644
--- a/integration/presets/longRunningApps.ts
+++ b/integration/presets/longRunningApps.ts
@@ -23,12 +23,14 @@ export const createLongRunningApps = () => {
{ id: 'react.vite.withEmailLinks', config: react.vite, env: envs.withEmailLinks },
{ id: 'remix.node.withEmailCodes', config: remix.remixNode, env: envs.withEmailCodes },
{ id: 'next.appRouter.withEmailCodes', config: next.appRouter, env: envs.withEmailCodes },
+ { id: 'next.appRouter.15RCwithEmailCodes', config: next.appRouter15Rc, env: envs.withEmailCodes },
{
id: 'next.appRouter.withEmailCodes_persist_client',
config: next.appRouter,
env: envs.withEmailCodes_destroy_client,
},
{ id: 'next.appRouter.withCustomRoles', config: next.appRouter, env: envs.withCustomRoles },
+ { id: 'next.appRouter.15RCwithCustomRoles', config: next.appRouter15Rc, env: envs.withCustomRoles },
{ id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart },
{ id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes },
{ id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles },
diff --git a/integration/presets/next.ts b/integration/presets/next.ts
index 68366b7ffa..f0a66b5802 100644
--- a/integration/presets/next.ts
+++ b/integration/presets/next.ts
@@ -16,6 +16,60 @@ const appRouter = applicationConfig()
.addDependency('react-dom', constants.E2E_REACT_DOM_VERSION)
.addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal);
+const appRouter15Rc = applicationConfig()
+ .setName('next-app-router')
+ .useTemplate(templates['next-app-router'])
+ .setEnvFormatter('public', key => `NEXT_PUBLIC_${key}`)
+ .addScript('setup', constants.E2E_NPM_FORCE ? 'npm i --force' : 'npm i')
+ .addScript('dev', 'npm run dev')
+ .addScript('build', 'npm run build')
+ .addScript('serve', 'npm run start')
+ .addDependency('next', 'rc')
+ .addDependency('react', 'rc')
+ .addDependency('react-dom', 'rc')
+ .addDependency('@clerk/nextjs', constants.E2E_CLERK_VERSION || clerkNextjsLocal)
+ .addFile(
+ 'src/middleware.ts',
+ () => `import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
+import { unstable_rethrow } from 'next/navigation';
+
+const csp = \`default-src 'self';
+ script-src 'self' 'unsafe-inline' 'unsafe-eval' 'strict-dynamic' 'nonce-deadbeef';
+ img-src 'self' https://img.clerk.com;
+ worker-src 'self' blob:;
+ style-src 'self' 'unsafe-inline';
+ frame-src 'self' https://challenges.cloudflare.com;
+\`;
+
+const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']);
+const isAdminRoute = createRouteMatcher(['/only-admin(.*)']);
+const isCSPRoute = createRouteMatcher(['/csp']);
+
+export default clerkMiddleware(async (auth, req) => {
+ if (isProtectedRoute(req)) {
+ try { await auth.protect() }
+ catch (e) { unstable_rethrow(e) }
+ }
+
+ if (isAdminRoute(req)) {
+ try { await auth.protect({role: 'admin'}) }
+ catch (e) { unstable_rethrow(e) }
+ }
+
+ if (isCSPRoute(req)) {
+ req.headers.set('Content-Security-Policy', csp.replace(/\\n/g, ''));
+ }
+});
+
+export const config = {
+ matcher: [
+ '/((?!.*\\\\..*|_next).*)', // Don't run middleware on static files
+ '/', // Run middleware on index page
+ '/(api|trpc)(.*)',
+ ], // Run middleware on API routes
+};`,
+ );
+
const appRouterTurbo = appRouter
.clone()
.setName('next-app-router-turbopack')
@@ -48,6 +102,7 @@ const appRouterAPWithClerkNextV4 = appRouterQuickstart
export const next = {
appRouter,
+ appRouter15Rc,
appRouterTurbo,
appRouterQuickstart,
appRouterAPWithClerkNextLatest,
diff --git a/integration/templates/next-app-router/src/app/only-admin/page.tsx b/integration/templates/next-app-router/src/app/only-admin/page.tsx
new file mode 100644
index 0000000000..a2817c4a6d
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/only-admin/page.tsx
@@ -0,0 +1,3 @@
+export default function Page() {
+ return
User is admin
;
+}
diff --git a/integration/templates/next-app-router/src/middleware.ts b/integration/templates/next-app-router/src/middleware.ts
index 71cc5ffa39..24c94de0e8 100644
--- a/integration/templates/next-app-router/src/middleware.ts
+++ b/integration/templates/next-app-router/src/middleware.ts
@@ -9,6 +9,7 @@ const csp = `default-src 'self';
`;
const isProtectedRoute = createRouteMatcher(['/protected(.*)', '/user(.*)', '/switcher(.*)']);
+const isAdminRoute = createRouteMatcher(['/only-admin(.*)']);
const isCSPRoute = createRouteMatcher(['/csp']);
export default clerkMiddleware(async (auth, req) => {
@@ -16,6 +17,10 @@ export default clerkMiddleware(async (auth, req) => {
await auth.protect();
}
+ if (isAdminRoute(req)) {
+ await auth.protect({ role: 'admin' });
+ }
+
if (isCSPRoute(req)) {
req.headers.set('Content-Security-Policy', csp.replace(/\n/g, ''));
}
diff --git a/integration/tests/protect.test.ts b/integration/tests/protect.test.ts
index 7b2c6868d2..fc5a5bc143 100644
--- a/integration/tests/protect.test.ts
+++ b/integration/tests/protect.test.ts
@@ -56,6 +56,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authoriz
await u.page.goToRelative('/settings/auth-protect');
await expect(u.page.getByText(/User has access/i)).toBeVisible();
+ await u.page.goToRelative('/only-admin');
+ await expect(u.page.getByText(/User is admin/i)).toBeVisible();
+
// route handler
await u.page.goToRelative('/api/settings/');
await expect(u.page.getByText(/userId/i)).toBeVisible();
@@ -89,6 +92,8 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authoriz
await u.po.signIn.waitForMounted();
await u.page.goToRelative('/page-protected');
await u.po.signIn.waitForMounted();
+ await u.page.goToRelative('/only-admin');
+ await u.po.signIn.waitForMounted();
});
test('Protect in RSCs and RCCs as `viewer`', async ({ page, context }) => {
@@ -114,6 +119,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('authoriz
await u.page.goToRelative('/settings/auth-protect');
await expect(u.page.getByText(/this page could not be found/i)).toBeVisible();
+ await u.page.goToRelative('/only-admin');
+ await expect(u.page.getByText(/this page could not be found/i)).toBeVisible();
+
// Route Handler
await u.page.goToRelative('/api/settings/').catch(() => {});
diff --git a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
index 7f8f60c437..318414d6cf 100644
--- a/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
+++ b/packages/nextjs/src/server/__tests__/clerkMiddleware.test.ts
@@ -496,7 +496,7 @@ describe('clerkMiddleware(params)', () => {
expect((await clerkClient()).authenticateRequest).toBeCalled();
});
- it('forwards headers from authenticateRequest when auth().protect() is called', async () => {
+ it('forwards headers from authenticateRequest when auth.protect() is called', async () => {
const req = mockRequest({
url: '/protected',
headers: new Headers({ [constants.Headers.SecFetchDest]: 'document' }),
diff --git a/packages/nextjs/src/server/clerkMiddleware.ts b/packages/nextjs/src/server/clerkMiddleware.ts
index a38ce924e9..52ace3c8d6 100644
--- a/packages/nextjs/src/server/clerkMiddleware.ts
+++ b/packages/nextjs/src/server/clerkMiddleware.ts
@@ -12,6 +12,14 @@ import { withLogger } from '../utils/debugLogger';
import { clerkClient } from './clerkClient';
import { PUBLISHABLE_KEY, SECRET_KEY, SIGN_IN_URL, SIGN_UP_URL } from './constants';
import { errorThrower } from './errorThrower';
+import {
+ isNextjsNotFoundError,
+ isNextjsRedirectError,
+ isRedirectToSignInError,
+ nextjsNotFound,
+ nextjsRedirectError,
+ redirectToSignInError,
+} from './nextErrors';
import type { AuthProtect } from './protect';
import { createProtect } from './protect';
import type { NextMiddlewareEvtParam, NextMiddlewareRequestParam, NextMiddlewareReturn } from './types';
@@ -23,12 +31,6 @@ import {
setRequestHeadersOnNextResponse,
} from './utils';
-const CONTROL_FLOW_ERROR = {
- FORCE_NOT_FOUND: 'CLERK_PROTECT_REWRITE',
- REDIRECT_TO_URL: 'CLERK_PROTECT_REDIRECT_TO_URL',
- REDIRECT_TO_SIGN_IN: 'CLERK_PROTECT_REDIRECT_TO_SIGN_IN',
-};
-
export type ClerkMiddlewareAuthObject = AuthObject & {
redirectToSignIn: RedirectFun;
};
@@ -58,16 +60,19 @@ interface ClerkMiddleware {
* export default clerkMiddleware((auth, request, event) => { ... }, options);
*/
(handler: ClerkMiddlewareHandler, options?: ClerkMiddlewareOptions): NextMiddleware;
+
/**
* @example
* export default clerkMiddleware((auth, request, event) => { ... }, (req) => options);
*/
(handler: ClerkMiddlewareHandler, options?: ClerkMiddlewareOptionsCallback): NextMiddleware;
+
/**
* @example
* export default clerkMiddleware(options);
*/
(options?: ClerkMiddlewareOptions): NextMiddleware;
+
/**
* @example
* export default clerkMiddleware;
@@ -228,9 +233,8 @@ const createMiddlewareRedirectToSignIn = (
clerkRequest: ClerkRequest,
): ClerkMiddlewareAuthObject['redirectToSignIn'] => {
return (opts = {}) => {
- const err = new Error(CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN) as any;
- err.returnBackUrl = opts.returnBackUrl === null ? '' : opts.returnBackUrl || clerkRequest.clerkUrl.toString();
- throw err;
+ const url = clerkRequest.clerkUrl.toString();
+ redirectToSignInError(url, opts.returnBackUrl);
};
};
@@ -240,15 +244,12 @@ const createMiddlewareProtect = (
redirectToSignIn: RedirectFun,
) => {
return (async (params: any, options: any) => {
- const notFound = () => {
- throw new Error(CONTROL_FLOW_ERROR.FORCE_NOT_FOUND) as any;
- };
+ const notFound = () => nextjsNotFound();
- const redirect = (url: string) => {
- const err = new Error(CONTROL_FLOW_ERROR.REDIRECT_TO_URL) as any;
- err.redirectUrl = url;
- throw err;
- };
+ const redirect = (url: string) =>
+ nextjsRedirectError(url, {
+ redirectUrl: url,
+ });
return createProtect({ request: clerkRequest, redirect, notFound, authObject, redirectToSignIn })(params, options);
}) as unknown as Promise;
@@ -262,25 +263,28 @@ const createMiddlewareProtect = (
// This function handles the known errors thrown by the APIs described above,
// and returns the appropriate response.
const handleControlFlowErrors = (e: any, clerkRequest: ClerkRequest, requestState: RequestState): Response => {
- switch (e.message) {
- case CONTROL_FLOW_ERROR.FORCE_NOT_FOUND:
- // Rewrite to a bogus URL to force not found error
- return setHeader(
- NextResponse.rewrite(`${clerkRequest.clerkUrl.origin}/clerk_${Date.now()}`),
- constants.Headers.AuthReason,
- 'protect-rewrite',
- );
- case CONTROL_FLOW_ERROR.REDIRECT_TO_URL:
- return redirectAdapter(e.redirectUrl);
- case CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN:
- return createRedirect({
- redirectAdapter,
- baseUrl: clerkRequest.clerkUrl,
- signInUrl: requestState.signInUrl,
- signUpUrl: requestState.signUpUrl,
- publishableKey: requestState.publishableKey,
- }).redirectToSignIn({ returnBackUrl: e.returnBackUrl });
- default:
- throw e;
+ if (isNextjsNotFoundError(e)) {
+ // Rewrite to a bogus URL to force not found error
+ return setHeader(
+ NextResponse.rewrite(`${clerkRequest.clerkUrl.origin}/clerk_${Date.now()}`),
+ constants.Headers.AuthReason,
+ 'protect-rewrite',
+ );
+ }
+
+ if (isRedirectToSignInError(e)) {
+ return createRedirect({
+ redirectAdapter,
+ baseUrl: clerkRequest.clerkUrl,
+ signInUrl: requestState.signInUrl,
+ signUpUrl: requestState.signUpUrl,
+ publishableKey: requestState.publishableKey,
+ }).redirectToSignIn({ returnBackUrl: e.returnBackUrl });
}
+
+ if (isNextjsRedirectError(e)) {
+ return redirectAdapter(e.redirectUrl);
+ }
+
+ throw e;
};
diff --git a/packages/nextjs/src/server/nextErrors.ts b/packages/nextjs/src/server/nextErrors.ts
new file mode 100644
index 0000000000..c0594fd3a3
--- /dev/null
+++ b/packages/nextjs/src/server/nextErrors.ts
@@ -0,0 +1,111 @@
+/**
+ * Clerk's identifiers that are used alongside the ones from Next.js
+ */
+const CONTROL_FLOW_ERROR = {
+ FORCE_NOT_FOUND: 'CLERK_PROTECT_REWRITE',
+ REDIRECT_TO_URL: 'CLERK_PROTECT_REDIRECT_TO_URL',
+ REDIRECT_TO_SIGN_IN: 'CLERK_PROTECT_REDIRECT_TO_SIGN_IN',
+};
+
+/**
+ * In-house implementation of `notFound()`
+ * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/not-found.ts
+ */
+const NOT_FOUND_ERROR_CODE = 'NEXT_NOT_FOUND';
+
+type NotFoundError = Error & {
+ digest: typeof NOT_FOUND_ERROR_CODE;
+ clerk_digest: typeof CONTROL_FLOW_ERROR.FORCE_NOT_FOUND;
+};
+
+function isNextjsNotFoundError(error: unknown): error is NotFoundError {
+ if (typeof error !== 'object' || error === null || !('digest' in error)) {
+ return false;
+ }
+
+ return error.digest === NOT_FOUND_ERROR_CODE;
+}
+
+function nextjsNotFound(): never {
+ const error = new Error(NOT_FOUND_ERROR_CODE);
+ (error as NotFoundError).digest = NOT_FOUND_ERROR_CODE;
+ (error as NotFoundError).clerk_digest = CONTROL_FLOW_ERROR.FORCE_NOT_FOUND;
+ throw error;
+}
+
+/**
+ * In-house implementation of `redirect()`
+ * https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/redirect.ts
+ */
+
+const REDIRECT_ERROR_CODE = 'NEXT_REDIRECT';
+
+type RedirectError = Error & {
+ digest: `${typeof REDIRECT_ERROR_CODE};${'replace'};${string};${307};`;
+ clerk_digest: typeof CONTROL_FLOW_ERROR.REDIRECT_TO_URL | typeof CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN;
+} & T;
+
+function nextjsRedirectError(
+ url: string,
+ extra: Record,
+ type: 'replace' = 'replace',
+ statusCode: 307 = 307,
+): never {
+ const error = new Error(REDIRECT_ERROR_CODE) as RedirectError;
+ error.digest = `${REDIRECT_ERROR_CODE};${type};${url};${statusCode};`;
+ error.clerk_digest = CONTROL_FLOW_ERROR.REDIRECT_TO_URL;
+ Object.assign(error, extra);
+ throw error;
+}
+
+function redirectToSignInError(url: string, returnBackUrl?: string | URL | null): never {
+ nextjsRedirectError(url, {
+ clerk_digest: CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN,
+ returnBackUrl: returnBackUrl === null ? '' : returnBackUrl || url,
+ });
+}
+
+/**
+ * Checks an error to determine if it's an error generated by the
+ * `redirect(url)` helper.
+ *
+ * @param error the error that may reference a redirect error
+ * @returns true if the error is a redirect error
+ */
+function isNextjsRedirectError(error: unknown): error is RedirectError<{ redirectUrl: string | URL }> {
+ if (typeof error !== 'object' || error === null || !('digest' in error) || typeof error.digest !== 'string') {
+ return false;
+ }
+
+ const digest = error.digest.split(';');
+ const [errorCode, type] = digest;
+ const destination = digest.slice(2, -2).join(';');
+ const status = digest.at(-2);
+
+ const statusCode = Number(status);
+
+ return (
+ errorCode === REDIRECT_ERROR_CODE &&
+ (type === 'replace' || type === 'push') &&
+ typeof destination === 'string' &&
+ !isNaN(statusCode) &&
+ statusCode === 307
+ );
+}
+
+function isRedirectToSignInError(error: unknown): error is RedirectError<{ returnBackUrl: string | URL }> {
+ if (isNextjsRedirectError(error) && 'clerk_digest' in error) {
+ return error.clerk_digest === CONTROL_FLOW_ERROR.REDIRECT_TO_SIGN_IN;
+ }
+
+ return false;
+}
+
+export {
+ isNextjsNotFoundError,
+ nextjsNotFound,
+ redirectToSignInError,
+ nextjsRedirectError,
+ isNextjsRedirectError,
+ isRedirectToSignInError,
+};