From 55d35721d8710e44e2ece30fb7e6cba1964cddaf Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 28 Aug 2025 16:12:49 -0700 Subject: [PATCH 01/35] feat(react-router): Introduce middleware to improve auth perf --- .changeset/quiet-bats-protect.md | 2 + .../react-router-library/package.json | 3 +- .../templates/react-router-node/app/root.tsx | 17 +- .../templates/react-router-node/package.json | 7 +- .../react-router-node/react-router.config.ts | 3 + integration/tests/react-router/basic.test.ts | 14 +- .../tests/react-router/pre-middleware.test.ts | 169 ++++++++++++++++++ packages/react-router/package.json | 11 +- .../src/server/__tests__/getAuth.test.ts | 55 ++++++ .../server/__tests__/rootAuthLoader.test.ts | 67 +++++++ .../react-router/src/server/clerkClient.ts | 21 +++ .../src/server/clerkMiddleware.ts | 61 +++++++ packages/react-router/src/server/getAuth.ts | 45 +++++ packages/react-router/src/server/index.ts | 4 + .../legacyAuthenticateRequest.ts} | 8 +- .../src/{ssr => server}/loadOptions.ts | 9 +- .../react-router/src/server/rootAuthLoader.ts | 169 ++++++++++++++++++ .../react-router/src/{ssr => server}/types.ts | 23 +-- .../react-router/src/{ssr => server}/utils.ts | 11 +- packages/react-router/src/ssr/getAuth.ts | 38 ---- packages/react-router/src/ssr/index.ts | 4 +- .../react-router/src/ssr/rootAuthLoader.ts | 98 ---------- packages/react-router/src/utils/errors.ts | 28 +++ pnpm-lock.yaml | 12 +- 24 files changed, 701 insertions(+), 178 deletions(-) create mode 100644 .changeset/quiet-bats-protect.md create mode 100644 integration/tests/react-router/pre-middleware.test.ts create mode 100644 packages/react-router/src/server/__tests__/getAuth.test.ts create mode 100644 packages/react-router/src/server/__tests__/rootAuthLoader.test.ts create mode 100644 packages/react-router/src/server/clerkClient.ts create mode 100644 packages/react-router/src/server/clerkMiddleware.ts create mode 100644 packages/react-router/src/server/getAuth.ts create mode 100644 packages/react-router/src/server/index.ts rename packages/react-router/src/{ssr/authenticateRequest.ts => server/legacyAuthenticateRequest.ts} (86%) rename packages/react-router/src/{ssr => server}/loadOptions.ts (90%) create mode 100644 packages/react-router/src/server/rootAuthLoader.ts rename packages/react-router/src/{ssr => server}/types.ts (96%) rename packages/react-router/src/{ssr => server}/utils.ts (95%) delete mode 100644 packages/react-router/src/ssr/getAuth.ts delete mode 100644 packages/react-router/src/ssr/rootAuthLoader.ts diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/quiet-bats-protect.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/integration/templates/react-router-library/package.json b/integration/templates/react-router-library/package.json index 28391f861f1..48db058cc61 100644 --- a/integration/templates/react-router-library/package.json +++ b/integration/templates/react-router-library/package.json @@ -9,10 +9,9 @@ "preview": "vite preview --port $PORT" }, "dependencies": { - "@clerk/react-router": "^0.1.2", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "^7.1.2" + "react-router": "^7.8.2" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 0bae3ebbc62..2ff3f1d91a9 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,11 +1,17 @@ -import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { rootAuthLoader } from '@clerk/react-router/ssr.server'; +import * as React from 'react'; +import { Await, isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; import { ClerkProvider } from '@clerk/react-router'; - import type { Route } from './+types/root'; +export const unstable_middleware: Route.unstable_MiddlewareFunction[] = [clerkMiddleware()]; + export async function loader(args: Route.LoaderArgs) { - return rootAuthLoader(args); + const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 1000)); + + return rootAuthLoader(args, () => ({ + nonCriticalData, + })); } export function Layout({ children }: { children: React.ReactNode }) { @@ -34,6 +40,9 @@ export default function App({ loaderData }: Route.ComponentProps) {
+ Loading...}> + {value =>

Non critical value: {value}

}
+
); diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index aabe6a20c32..cebb56d7da9 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -9,16 +9,15 @@ "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { - "@clerk/react-router": "latest", - "@react-router/node": "^7.1.2", - "@react-router/serve": "^7.1.2", + "@react-router/node": "^7.8.2", + "@react-router/serve": "^7.8.2", "isbot": "^5.1.17", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router": "^7.1.2" }, "devDependencies": { - "@react-router/dev": "^7.1.2", + "@react-router/dev": "^7.8.2", "@types/node": "^20", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/integration/templates/react-router-node/react-router.config.ts b/integration/templates/react-router-node/react-router.config.ts index 4f9a6ed5228..8f242de5639 100644 --- a/integration/templates/react-router-node/react-router.config.ts +++ b/integration/templates/react-router-node/react-router.config.ts @@ -4,4 +4,7 @@ export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` ssr: true, + future: { + unstable_middleware: true, + }, } satisfies Config; diff --git a/integration/tests/react-router/basic.test.ts b/integration/tests/react-router/basic.test.ts index 595a724304b..526710c1c76 100644 --- a/integration/tests/react-router/basic.test.ts +++ b/integration/tests/react-router/basic.test.ts @@ -5,7 +5,7 @@ import type { FakeUser } from '../../testUtils'; import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: ['react-router.node'] })( - 'basic tests for @react-router', + 'basic tests for @react-router with middleware', ({ app }) => { test.describe.configure({ mode: 'parallel' }); @@ -88,5 +88,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: await expect(u.page.getByText(`First name: ${fakeUser.firstName}`)).toBeVisible(); await expect(u.page.getByText(`Email: ${fakeUser.email}`)).toBeVisible(); }); + + test.skip('streaming with Suspense works with rootAuthLoader', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/'); + + await expect(u.page.getByText('Loading...')).toBeVisible(); + + // Wait for the streaming content to resolve + await expect(u.page.getByText('Non critical value: non-critical')).toBeVisible({ timeout: 3000 }); + await expect(u.page.getByText('Loading...')).toBeHidden(); + }); }, ); diff --git a/integration/tests/react-router/pre-middleware.test.ts b/integration/tests/react-router/pre-middleware.test.ts new file mode 100644 index 00000000000..3cb80691d2d --- /dev/null +++ b/integration/tests/react-router/pre-middleware.test.ts @@ -0,0 +1,169 @@ +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { appConfigs } from '../../presets'; +import type { FakeUser } from '../../testUtils'; +import { createTestUtils } from '../../testUtils'; + +test.describe('basic tests for @react-router without middleware', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + let fakeUser: FakeUser; + + test.beforeAll(async () => { + test.setTimeout(90_000); // Wait for app to be ready + app = await appConfigs.reactRouter.reactRouterNode + .clone() + .addFile( + `app/root.tsx`, + () => `import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; +import { rootAuthLoader } from '@clerk/react-router/ssr.server'; +import { ClerkProvider } from '@clerk/react-router'; + +import type { Route } from './+types/root'; + +export async function loader(args: Route.LoaderArgs) { + return rootAuthLoader(args); +} + +export function Layout({ children }: { children: React.ReactNode }) { + return ( + + + + + + + + + {children} + + + + + ); +} + +export default function App({ loaderData }: Route.ComponentProps) { + return ( + +
+ +
+
+ ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + let message = 'Oops!'; + let details = 'An unexpected error occurred.'; + let stack: string | undefined; + + if (isRouteErrorResponse(error)) { + message = error.status === 404 ? '404' : 'Error'; + details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; + } else if (import.meta.env.DEV && error && error instanceof Error) { + details = error.message; + stack = error.stack; + } + + return ( +
+

{message}

+

{details}

+ {stack && ( +
+          {stack}
+        
+ )} +
+ ); +} +`, + ) + .commit(); + + await app.setup(); + await app.withEnv(appConfigs.envs.withEmailCodes); + await app.dev(); + + const u = createTestUtils({ app }); + fakeUser = u.services.users.createFakeUser({ + fictionalEmail: true, + withPhoneNumber: true, + withUsername: true, + }); + await u.services.users.createBapiUser(fakeUser); + }); + + test.afterAll(async () => { + await fakeUser.deleteIfExists(); + await app.teardown(); + }); + + test.afterEach(async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.signOut(); + await u.page.context().clearCookies(); + }); + + test('can sign in and user button renders', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.po.signIn.goTo(); + + await u.po.signIn.setIdentifier(fakeUser.email); + await u.po.signIn.setPassword(fakeUser.password); + await u.po.signIn.continue(); + await u.po.expect.toBeSignedIn(); + + await u.page.waitForAppUrl('/'); + + await u.po.userButton.waitForMounted(); + await u.po.userButton.toggleTrigger(); + await u.po.userButton.waitForPopover(); + + await u.po.userButton.toHaveVisibleMenuItems([/Manage account/i, /Sign out$/i]); + }); + + test('redirects to sign-in when unauthenticated', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/protected'); + await u.page.waitForURL(`${app.serverUrl}/sign-in`); + await u.po.signIn.waitForMounted(); + }); + + test('renders control components contents', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToAppHome(); + await expect(u.page.getByText('SignedOut')).toBeVisible(); + + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + await expect(u.page.getByText('SignedIn')).toBeVisible(); + }); + + test('renders user profile with SSR data', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + + await u.page.goToRelative('/sign-in'); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password }); + await u.po.expect.toBeSignedIn(); + + await u.po.userButton.waitForMounted(); + await u.page.goToRelative('/protected'); + await u.po.userProfile.waitForMounted(); + + // Fetched from an API endpoint (/api/me), which is server-rendered. + // This also verifies that the server middleware is working. + await expect(u.page.getByText(`First name: ${fakeUser.firstName}`)).toBeVisible(); + await expect(u.page.getByText(`Email: ${fakeUser.email}`)).toBeVisible(); + }); +}); diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 93e05eba310..e1fc6b98c85 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -30,6 +30,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./server": { + "types": "./dist/server/index.d.ts", + "default": "./dist/server/index.js" + }, "./ssr.server": { "types": "./dist/ssr/index.d.ts", "default": "./dist/ssr/index.js" @@ -55,6 +59,9 @@ "dist/*.d.ts", "dist/index.d.ts" ], + "server": [ + "dist/server/index.d.ts" + ], "ssr.server": [ "dist/ssr/index.d.ts" ], @@ -92,12 +99,12 @@ "devDependencies": { "@types/cookie": "^0.6.0", "esbuild-plugin-file-path-extensions": "^2.1.4", - "react-router": "7.8.1" + "react-router": "7.8.2" }, "peerDependencies": { "react": "catalog:peer-react", "react-dom": "catalog:peer-react", - "react-router": "^7.1.2" + "react-router": "^7.8.2" }, "engines": { "node": ">=20.0.0" diff --git a/packages/react-router/src/server/__tests__/getAuth.test.ts b/packages/react-router/src/server/__tests__/getAuth.test.ts new file mode 100644 index 00000000000..c7311944fa8 --- /dev/null +++ b/packages/react-router/src/server/__tests__/getAuth.test.ts @@ -0,0 +1,55 @@ +import type { LoaderFunctionArgs } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getAuth } from '../getAuth'; +import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; + +vi.mock('../legacyAuthenticateRequest', () => { + return { + legacyAuthenticateRequest: vi.fn().mockResolvedValue({ + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx' }), + headers: new Headers(), + status: 'signed-in', + }), + }; +}); + +describe('getAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CLERK_SECRET_KEY = 'sk_test_...'; + }); + + it('should not call legacyAuthenticateRequest when middleware context exists', async () => { + const mockContext = { + get: vi.fn().mockReturnValue((options?: any) => ({ + userId: 'user_xxx', + ...options, + })), + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + await getAuth(args); + + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should call legacyAuthenticateRequest when middleware context is missing', async () => { + const mockContext = { + get: vi.fn().mockReturnValue(null), + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + await getAuth(args); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); +}); diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts new file mode 100644 index 00000000000..c189e2236c7 --- /dev/null +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -0,0 +1,67 @@ +import type { LoaderFunctionArgs } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { middlewareMigrationWarning } from '../../utils/errors'; +import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; +import { rootAuthLoader } from '../rootAuthLoader'; + +vi.mock('../legacyAuthenticateRequest', () => { + return { + legacyAuthenticateRequest: vi.fn().mockResolvedValue({ + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx' }), + headers: new Headers(), + status: 'signed-in', + }), + }; +}); + +describe('rootAuthLoader', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.CLERK_SECRET_KEY = 'sk_test_...'; + }); + + it('should not call legacyAuthenticateRequest when middleware context exists', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mockContext = { + get: vi.fn().mockReturnValue({ + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx' }), + }), + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + await rootAuthLoader(args, () => ({ data: 'test' })); + + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); + + consoleWarnSpy.mockRestore(); + }); + + it('should call legacyAuthenticateRequest when middleware context is missing', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const mockContext = { + get: vi.fn().mockReturnValue(null), + }; + + const args = { + context: mockContext, + request: new Request('http://clerk.com'), + } as LoaderFunctionArgs; + + await rootAuthLoader(args, () => ({ data: 'test' })); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + + expect(consoleWarnSpy).toHaveBeenCalledWith(middlewareMigrationWarning); + + consoleWarnSpy.mockRestore(); + }); +}); diff --git a/packages/react-router/src/server/clerkClient.ts b/packages/react-router/src/server/clerkClient.ts new file mode 100644 index 00000000000..3c52229da3b --- /dev/null +++ b/packages/react-router/src/server/clerkClient.ts @@ -0,0 +1,21 @@ +import { createClerkClient } from '@clerk/backend'; + +import { type DataFunctionArgs, loadOptions } from './loadOptions'; + +export const clerkClient = (args: DataFunctionArgs) => { + const options = loadOptions(args); + + const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, machineSecretKey } = options; + + return createClerkClient({ + apiUrl, + secretKey, + jwtKey, + proxyUrl, + isSatellite, + domain, + publishableKey, + machineSecretKey, + userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, + }); +}; diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts new file mode 100644 index 00000000000..cd69c09ad6a --- /dev/null +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -0,0 +1,61 @@ +import type { AuthObject } from '@clerk/backend'; +import type { RequestState } from '@clerk/backend/internal'; +import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; +import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import type { PendingSessionOptions } from '@clerk/types'; +import type { unstable_MiddlewareFunction } from 'react-router'; +import { unstable_createContext } from 'react-router'; + +import { clerkClient } from './clerkClient'; +import { loadOptions } from './loadOptions'; +import type { ClerkMiddlewareOptions } from './types'; +import { patchRequest } from './utils'; + +export const authFnContext = unstable_createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); +export const requestStateContext = unstable_createContext | null>(null); + +export const clerkMiddleware = (options?: ClerkMiddlewareOptions): unstable_MiddlewareFunction => { + return async (args, next) => { + const clerkRequest = createClerkRequest(patchRequest(args.request)); + const loadedOptions = loadOptions(args, options); + const { audience, authorizedParties } = loadedOptions; + const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = loadedOptions; + const requestState = await clerkClient(args).authenticateRequest(clerkRequest, { + audience, + authorizedParties, + signInUrl, + signUpUrl, + afterSignInUrl, + afterSignUpUrl, + acceptsToken: 'any', + }); + + const locationHeader = requestState.headers.get(constants.Headers.Location); + if (locationHeader) { + handleNetlifyCacheInDevInstance({ + locationHeader, + requestStateHeaders: requestState.headers, + publishableKey: requestState.publishableKey, + }); + // Trigger a handshake redirect + return new Response(null, { status: 307, headers: requestState.headers }); + } + + if (requestState.status === AuthStatus.Handshake) { + throw new Error('Clerk: handshake status without redirect'); + } + + args.context.set(authFnContext, (options?: PendingSessionOptions) => requestState.toAuth(options)); + args.context.set(requestStateContext, requestState); + + const response = await next(); + + if (requestState.headers) { + requestState.headers.forEach((value, key) => { + response.headers.append(key, value); + }); + } + + return response; + }; +}; diff --git a/packages/react-router/src/server/getAuth.ts b/packages/react-router/src/server/getAuth.ts new file mode 100644 index 00000000000..07f879038b6 --- /dev/null +++ b/packages/react-router/src/server/getAuth.ts @@ -0,0 +1,45 @@ +import { + type AuthenticateRequestOptions, + type GetAuthFn, + getAuthObjectForAcceptedToken, +} from '@clerk/backend/internal'; +import type { PendingSessionOptions } from '@clerk/types'; +import type { LoaderFunctionArgs } from 'react-router'; + +import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; +import { authFnContext } from './clerkMiddleware'; +import { legacyAuthenticateRequest } from './legacyAuthenticateRequest'; +import { loadOptions } from './loadOptions'; + +type GetAuthOptions = PendingSessionOptions & { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] }; + +export const getAuth: GetAuthFn = (async ( + args: LoaderFunctionArgs, + opts?: GetAuthOptions, +) => { + if (!args || (args && (!args.request || !args.context))) { + throw new Error(noLoaderArgsPassedInGetAuth); + } + + const { acceptsToken, treatPendingAsSignedOut, ...restOptions } = opts || {}; + + // If the middleware is installed, use the auth function from the context + const authObjectFn = args.context.get(authFnContext); + if (authObjectFn) { + return getAuthObjectForAcceptedToken({ + authObject: authObjectFn({ treatPendingAsSignedOut }), + acceptsToken, + }); + } + + // Fallback to the legacy authenticateRequest if the middleware is not installed + const loadedOptions = loadOptions(args, restOptions); + const requestState = await legacyAuthenticateRequest(args, { + ...loadedOptions, + acceptsToken: 'any', + }); + + const authObject = requestState.toAuth({ treatPendingAsSignedOut }); + + return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); +}) as GetAuthFn; diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts new file mode 100644 index 00000000000..0e85b3f56f0 --- /dev/null +++ b/packages/react-router/src/server/index.ts @@ -0,0 +1,4 @@ +export * from '@clerk/backend'; +export { clerkMiddleware } from './clerkMiddleware'; +export { rootAuthLoader } from './rootAuthLoader'; +export { getAuth } from './getAuth'; diff --git a/packages/react-router/src/ssr/authenticateRequest.ts b/packages/react-router/src/server/legacyAuthenticateRequest.ts similarity index 86% rename from packages/react-router/src/ssr/authenticateRequest.ts rename to packages/react-router/src/server/legacyAuthenticateRequest.ts index a0c434ca881..7b2d704cee8 100644 --- a/packages/react-router/src/ssr/authenticateRequest.ts +++ b/packages/react-router/src/server/legacyAuthenticateRequest.ts @@ -1,12 +1,12 @@ -import { createClerkClient } from '@clerk/backend'; import type { AuthenticateRequestOptions, SignedInState, SignedOutState } from '@clerk/backend/internal'; import { AuthStatus, constants } from '@clerk/backend/internal'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import type { LoaderFunctionArgs } from 'react-router'; +import { clerkClient } from './clerkClient'; import { patchRequest } from './utils'; -export async function authenticateRequest( +export async function legacyAuthenticateRequest( args: LoaderFunctionArgs, opts: AuthenticateRequestOptions, ): Promise { @@ -16,7 +16,7 @@ export async function authenticateRequest( const { apiUrl, secretKey, jwtKey, proxyUrl, isSatellite, domain, publishableKey, machineSecretKey } = opts; const { signInUrl, signUpUrl, afterSignInUrl, afterSignUpUrl } = opts; - const requestState = await createClerkClient({ + const requestState = await clerkClient(args).authenticateRequest(patchRequest(request), { apiUrl, secretKey, jwtKey, @@ -25,8 +25,6 @@ export async function authenticateRequest( domain, publishableKey, machineSecretKey, - userAgent: `${PACKAGE_NAME}@${PACKAGE_VERSION}`, - }).authenticateRequest(patchRequest(request), { audience, authorizedParties, signInUrl, diff --git a/packages/react-router/src/ssr/loadOptions.ts b/packages/react-router/src/server/loadOptions.ts similarity index 90% rename from packages/react-router/src/ssr/loadOptions.ts rename to packages/react-router/src/server/loadOptions.ts index 969cce2dd12..f2710c8ebbe 100644 --- a/packages/react-router/src/ssr/loadOptions.ts +++ b/packages/react-router/src/server/loadOptions.ts @@ -4,14 +4,17 @@ import { getEnvVariable } from '@clerk/shared/getEnvVariable'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; import { handleValueOrFn } from '@clerk/shared/utils'; -import type { LoaderFunctionArgs } from 'react-router'; +import type { unstable_MiddlewareFunction } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from '../utils/errors'; -import type { RootAuthLoaderOptions } from './types'; +import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; -export const loadOptions = (args: LoaderFunctionArgs, overrides: RootAuthLoaderOptions = {}) => { +export type DataFunctionArgs = Parameters>[0]; + +export const loadOptions = (args: DataFunctionArgs, overrides: ClerkMiddlewareOptions = {}) => { + // see https://developers.cloudflare.com/workers/framework-guides/web-apps/react-router/#use-bindings-with-react-router const { request, context } = args; const clerkRequest = createClerkRequest(patchRequest(request)); diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts new file mode 100644 index 00000000000..39173ef29cf --- /dev/null +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -0,0 +1,169 @@ +import type { RequestState } from '@clerk/backend/internal'; +import { decorateObjectWithResources } from '@clerk/backend/internal'; +import { logger } from '@clerk/shared/logger'; +import type { LoaderFunctionArgs } from 'react-router'; + +import { invalidRootLoaderCallbackReturn, middlewareMigrationWarning } from '../utils/errors'; +import { authFnContext, requestStateContext } from './clerkMiddleware'; +import { legacyAuthenticateRequest } from './legacyAuthenticateRequest'; +import { loadOptions } from './loadOptions'; +import type { + LoaderFunctionArgsWithAuth, + LoaderFunctionReturn, + RootAuthLoaderCallback, + RootAuthLoaderOptions, +} from './types'; +import { + getResponseClerkState, + injectRequestStateIntoResponse, + isDataWithResponseInit, + isRedirect, + isResponse, +} from './utils'; + +interface RootAuthLoader { + >( + /** + * Arguments passed to the loader function. + */ + args: LoaderFunctionArgs, + /** + * A loader function with authentication state made available to it. Allows you to fetch route data based on the user's authentication state. + */ + callback: Callback, + options?: Options, + ): Promise>; + + (args: LoaderFunctionArgs, options?: RootAuthLoaderOptions): Promise; +} + +/** + * Shared logic for processing the root auth loader with a given request state + */ +async function processRootAuthLoader( + args: LoaderFunctionArgs, + requestState: RequestState, + handler?: RootAuthLoaderCallback, +): Promise { + const hasMiddleware = !!args.context.get(authFnContext); + const includeClerkHeaders = !hasMiddleware; + + if (!handler) { + // if the user did not provide a handler, simply inject requestState into an empty response + const { clerkState } = getResponseClerkState(requestState, args.context); + return { + ...clerkState, + }; + } + + // Create args that has the auth object in the request for backward compatibility + const argsWithAuth = { + ...args, + request: Object.assign(args.request, { auth: requestState.toAuth() }), + } as LoaderFunctionArgsWithAuth; + + const handlerResult = await handler(argsWithAuth); + + if (isResponse(handlerResult)) { + try { + // respect and pass-through any redirects without modifying them + if (isRedirect(handlerResult)) { + return handlerResult; + } + // clone and try to inject requestState into all json-like responses + // if this fails, the user probably didn't return a json object or a valid json string + return injectRequestStateIntoResponse(handlerResult, requestState, args.context, includeClerkHeaders); + } catch { + throw new Error(invalidRootLoaderCallbackReturn); + } + } + + if (isDataWithResponseInit(handlerResult)) { + try { + // clone and try to inject requestState into all json-like responses + // if this fails, the user probably didn't return a json object or a valid json string + return injectRequestStateIntoResponse( + new Response(JSON.stringify(handlerResult.data), handlerResult.init ?? undefined), + requestState, + args.context, + includeClerkHeaders, + ); + } catch { + throw new Error(invalidRootLoaderCallbackReturn); + } + } + + // If the return value of the user's handler is null or a plain object + if (includeClerkHeaders) { + // Legacy path: return Response with headers + const responseBody = JSON.stringify(handlerResult ?? {}); + return injectRequestStateIntoResponse(new Response(responseBody), requestState, args.context, includeClerkHeaders); + } + + // Middleware path: return plain object with streaming support + const { clerkState } = getResponseClerkState(requestState, args.context); + return { + ...(handlerResult ?? {}), + ...clerkState, + }; +} + +/** + * Makes authorization state available in your application by wrapping the root loader. + * + * @see https://clerk.com/docs/references/react-router/root-auth-loader + */ +export const rootAuthLoader: RootAuthLoader = async ( + args: LoaderFunctionArgs, + handlerOrOptions: any, + options?: any, +): Promise => { + const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined; + const opts: RootAuthLoaderOptions = options + ? options + : !!handlerOrOptions && typeof handlerOrOptions !== 'function' + ? handlerOrOptions + : {}; + + const requestState = args.context.get(requestStateContext); + + if (!requestState) { + logger.warnOnce(middlewareMigrationWarning); + return legacyRootAuthLoader(args, handlerOrOptions, opts); + } + + return processRootAuthLoader(args, requestState, handler); +}; + +/** + * Legacy implementation that authenticates requests without middleware. + * This maintains backward compatibility for users who haven't migrated to the new middleware system. + */ +const legacyRootAuthLoader: RootAuthLoader = async ( + args: LoaderFunctionArgs, + handlerOrOptions: any, + options?: any, +): Promise => { + const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined; + const opts: RootAuthLoaderOptions = options + ? options + : !!handlerOrOptions && typeof handlerOrOptions !== 'function' + ? handlerOrOptions + : {}; + + const loadedOptions = loadOptions(args, opts); + // Note: legacyAuthenticateRequest() will throw a redirect if the auth state is determined to be handshake + const _requestState = await legacyAuthenticateRequest(args, loadedOptions); + const requestState = { ...loadedOptions, ..._requestState }; + + if (!handler) { + // if the user did not provide a handler, simply inject requestState into an empty response + return injectRequestStateIntoResponse(new Response(JSON.stringify({})), requestState, args.context, true); + } + + const authObj = requestState.toAuth(); + const requestWithAuth = Object.assign(args.request, { auth: authObj }); + await decorateObjectWithResources(requestWithAuth, authObj, loadedOptions); + + return processRootAuthLoader(args, requestState, handler); +}; diff --git a/packages/react-router/src/ssr/types.ts b/packages/react-router/src/server/types.ts similarity index 96% rename from packages/react-router/src/ssr/types.ts rename to packages/react-router/src/server/types.ts index 87d54d5994a..9f8e0847496 100644 --- a/packages/react-router/src/ssr/types.ts +++ b/packages/react-router/src/server/types.ts @@ -12,7 +12,7 @@ import type { LoaderFunction, LoaderFunctionArgs, UNSAFE_DataWithResponseInit } export type GetAuthReturn = Promise; -export type RootAuthLoaderOptions = { +export type ClerkMiddlewareOptions = { /** * Used to override the default VITE_CLERK_PUBLISHABLE_KEY env variable if needed. */ @@ -29,6 +29,17 @@ export type RootAuthLoaderOptions = { * Used to override the CLERK_MACHINE_SECRET_KEY env variable if needed. */ machineSecretKey?: string; + signInUrl?: string; + signUpUrl?: string; +} & Pick & + MultiDomainAndOrProxy & + SignInForceRedirectUrl & + SignInFallbackRedirectUrl & + SignUpForceRedirectUrl & + SignUpFallbackRedirectUrl & + LegacyRedirectProps; + +export type RootAuthLoaderOptions = ClerkMiddlewareOptions & { /** * @deprecated Use [session token claims](https://clerk.com/docs/backend-requests/making/custom-session-token) instead. */ @@ -41,15 +52,7 @@ export type RootAuthLoaderOptions = { * @deprecated Use [session token claims](https://clerk.com/docs/backend-requests/making/custom-session-token) instead. */ loadOrganization?: boolean; - signInUrl?: string; - signUpUrl?: string; -} & Pick & - MultiDomainAndOrProxy & - SignInForceRedirectUrl & - SignInFallbackRedirectUrl & - SignUpForceRedirectUrl & - SignUpFallbackRedirectUrl & - LegacyRedirectProps; +}; export type RequestStateWithRedirectUrls = RequestState & SignInForceRedirectUrl & diff --git a/packages/react-router/src/ssr/utils.ts b/packages/react-router/src/server/utils.ts similarity index 95% rename from packages/react-router/src/ssr/utils.ts rename to packages/react-router/src/server/utils.ts index 376539c3bc9..599a8efd780 100644 --- a/packages/react-router/src/ssr/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -44,6 +44,7 @@ export const injectRequestStateIntoResponse = async ( response: Response, requestState: RequestStateWithRedirectUrls, context: AppLoadContext, + includeClerkHeaders = false, ) => { const clone = new Response(response.body, response); const data = await clone.json(); @@ -52,9 +53,13 @@ export const injectRequestStateIntoResponse = async ( // set the correct content-type header in case the user returned a `Response` directly clone.headers.set(constants.Headers.ContentType, constants.ContentTypes.Json); - headers.forEach((value, key) => { - clone.headers.append(key, value); - }); + + // Only add Clerk headers if requested (for legacy mode) + if (includeClerkHeaders) { + headers.forEach((value, key) => { + clone.headers.append(key, value); + }); + } return Response.json({ ...(data || {}), ...clerkState }, clone); }; diff --git a/packages/react-router/src/ssr/getAuth.ts b/packages/react-router/src/ssr/getAuth.ts deleted file mode 100644 index bc488619444..00000000000 --- a/packages/react-router/src/ssr/getAuth.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { - type AuthenticateRequestOptions, - type GetAuthFn, - getAuthObjectForAcceptedToken, -} from '@clerk/backend/internal'; -import type { LoaderFunctionArgs } from 'react-router'; - -import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; -import { authenticateRequest } from './authenticateRequest'; -import { loadOptions } from './loadOptions'; -import type { RootAuthLoaderOptions } from './types'; - -type GetAuthOptions = { acceptsToken?: AuthenticateRequestOptions['acceptsToken'] } & Pick< - RootAuthLoaderOptions, - 'secretKey' ->; - -export const getAuth: GetAuthFn = (async ( - args: LoaderFunctionArgs, - opts?: GetAuthOptions, -) => { - if (!args || (args && (!args.request || !args.context))) { - throw new Error(noLoaderArgsPassedInGetAuth); - } - - const { acceptsToken, ...restOptions } = opts || {}; - - const loadedOptions = loadOptions(args, restOptions); - // Note: authenticateRequest() will throw a redirect if the auth state is determined to be handshake - const requestState = await authenticateRequest(args, { - ...loadedOptions, - acceptsToken: 'any', - }); - - const authObject = requestState.toAuth(); - - return getAuthObjectForAcceptedToken({ authObject, acceptsToken }); -}) as GetAuthFn; diff --git a/packages/react-router/src/ssr/index.ts b/packages/react-router/src/ssr/index.ts index fcd02aa9159..aa8235f0eed 100644 --- a/packages/react-router/src/ssr/index.ts +++ b/packages/react-router/src/ssr/index.ts @@ -1,5 +1,5 @@ -export * from './rootAuthLoader'; -export * from './getAuth'; +export { rootAuthLoader } from '../server/rootAuthLoader'; +export { getAuth } from '../server/getAuth'; /** * Re-export resource types from @clerk/backend diff --git a/packages/react-router/src/ssr/rootAuthLoader.ts b/packages/react-router/src/ssr/rootAuthLoader.ts deleted file mode 100644 index 688df50fbdf..00000000000 --- a/packages/react-router/src/ssr/rootAuthLoader.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { decorateObjectWithResources } from '@clerk/backend/internal'; -import type { LoaderFunctionArgs } from 'react-router'; - -import { invalidRootLoaderCallbackReturn } from '../utils/errors'; -import { authenticateRequest } from './authenticateRequest'; -import { loadOptions } from './loadOptions'; -import type { LoaderFunctionReturn, RootAuthLoaderCallback, RootAuthLoaderOptions } from './types'; -import { - assertValidHandlerResult, - injectRequestStateIntoResponse, - isDataWithResponseInit, - isRedirect, - isResponse, -} from './utils'; - -interface RootAuthLoader { - >( - /** - * Arguments passed to the loader function. - */ - args: LoaderFunctionArgs, - /** - * A loader function with authentication state made available to it. Allows you to fetch route data based on the user's authentication state. - */ - callback: Callback, - options?: Options, - ): Promise>; - - (args: LoaderFunctionArgs, options?: RootAuthLoaderOptions): Promise; -} - -/** - * Makes authorization state available in your application by wrapping the root loader. - * - * @see https://clerk.com/docs/quickstarts/react-router - */ -export const rootAuthLoader: RootAuthLoader = async ( - args: LoaderFunctionArgs, - handlerOrOptions: any, - options?: any, -): Promise => { - const handler = typeof handlerOrOptions === 'function' ? handlerOrOptions : undefined; - const opts: RootAuthLoaderOptions = options - ? options - : !!handlerOrOptions && typeof handlerOrOptions !== 'function' - ? handlerOrOptions - : {}; - - const loadedOptions = loadOptions(args, opts); - // Note: authenticateRequest() will throw a redirect if the auth state is determined to be handshake - const _requestState = await authenticateRequest(args, loadedOptions); - // TODO: Investigate if `authenticateRequest` needs to return the loadedOptions (the new request urls in particular) - const requestState = { ...loadedOptions, ..._requestState }; - - if (!handler) { - // if the user did not provide a handler, simply inject requestState into an empty response - return injectRequestStateIntoResponse(new Response(JSON.stringify({})), requestState, args.context); - } - - const authObj = requestState.toAuth(); - const requestWithAuth = Object.assign(args.request, { auth: authObj }); - await decorateObjectWithResources(requestWithAuth, authObj, loadedOptions); - const handlerResult = await handler(args); - assertValidHandlerResult(handlerResult, invalidRootLoaderCallbackReturn); - - if (isResponse(handlerResult)) { - try { - // respect and pass-through any redirects without modifying them - if (isRedirect(handlerResult)) { - return handlerResult; - } - // clone and try to inject requestState into all json-like responses - // if this fails, the user probably didn't return a json object or a valid json string - return injectRequestStateIntoResponse(handlerResult, requestState, args.context); - } catch { - throw new Error(invalidRootLoaderCallbackReturn); - } - } - - if (isDataWithResponseInit(handlerResult)) { - try { - // clone and try to inject requestState into all json-like responses - // if this fails, the user probably didn't return a json object or a valid json string - return injectRequestStateIntoResponse( - new Response(JSON.stringify(handlerResult.data), handlerResult.init ?? undefined), - requestState, - args.context, - ); - } catch { - throw new Error(invalidRootLoaderCallbackReturn); - } - } - - // if the return value of the user's handler is null or a plain object, create an empty response to inject Clerk's state into - const responseBody = JSON.stringify(handlerResult ?? {}); - - return injectRequestStateIntoResponse(new Response(responseBody), requestState, args.context); -}; diff --git a/packages/react-router/src/utils/errors.ts b/packages/react-router/src/utils/errors.ts index 8e0a7682f2b..2fddeef28b4 100644 --- a/packages/react-router/src/utils/errors.ts +++ b/packages/react-router/src/utils/errors.ts @@ -94,3 +94,31 @@ Example: `); + +const middlewareMigrationExample = `In the next major release, an error will be thrown if the middleware is not installed. + +Example: + +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/ssr.server' +import { ClerkProvider } from '@clerk/react-router' + +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] + +export async function loader(args: Route.LoaderArgs) { + return rootAuthLoader(args) +} + +export default function App({ loaderData }: Route.ComponentProps) { + return ( + + + + ) +} +`; + +export const middlewareMigrationWarning = createErrorMessage(` +'"clerkMiddleware()" not detected. + +${middlewareMigrationExample} +`); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 637e158a697..799aec194d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -874,8 +874,8 @@ importers: specifier: ^2.1.4 version: 2.1.4 react-router: - specifier: 7.8.1 - version: 7.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.8.2 + version: 7.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) packages/remix: dependencies: @@ -2908,7 +2908,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -12312,8 +12312,8 @@ packages: peerDependencies: react: '>=16.8' - react-router@7.8.1: - resolution: {integrity: sha512-5cy/M8DHcG51/KUIka1nfZ2QeylS4PJRs6TT8I4PF5axVsI5JUxp0hC0NZ/AEEj8Vw7xsEoD7L/6FY+zoYaOGA==} + react-router@7.8.2: + resolution: {integrity: sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -29159,7 +29159,7 @@ snapshots: '@remix-run/router': 1.23.0 react: 18.3.1 - react-router@7.8.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router@7.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: cookie: 1.0.2 react: 18.3.1 From ca486bc22677615eab18714582770edd7ad7c84a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 28 Aug 2025 16:57:25 -0700 Subject: [PATCH 02/35] test(react-router): Unit test conditional response --- .../server/__tests__/rootAuthLoader.test.ts | 130 ++++++++++++++++-- .../react-router/src/server/rootAuthLoader.ts | 1 + 2 files changed, 116 insertions(+), 15 deletions(-) diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index c189e2236c7..5f9cdeb6f8f 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -1,4 +1,4 @@ -import type { LoaderFunctionArgs } from 'react-router'; +import { data, type LoaderFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { middlewareMigrationWarning } from '../../utils/errors'; @@ -21,13 +21,12 @@ describe('rootAuthLoader', () => { process.env.CLERK_SECRET_KEY = 'sk_test_...'; }); - it('should not call legacyAuthenticateRequest when middleware context exists', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - + describe('with middleware context', () => { const mockContext = { get: vi.fn().mockReturnValue({ toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx' }), }), + set: vi.fn(), }; const args = { @@ -35,18 +34,68 @@ describe('rootAuthLoader', () => { request: new Request('http://clerk.com'), } as LoaderFunctionArgs; - await rootAuthLoader(args, () => ({ data: 'test' })); + it('should not call legacyAuthenticateRequest when middleware context exists', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); - expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + await rootAuthLoader(args, () => ({ data: 'test' })); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + expect(consoleWarnSpy).not.toHaveBeenCalled(); - consoleWarnSpy.mockRestore(); - }); + consoleWarnSpy.mockRestore(); + }); + + it('should handle no callback - returns clerkState only', async () => { + const result = await rootAuthLoader(args); + + expect(result).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning a Response', async () => { + const mockResponse = new Response(JSON.stringify({ message: 'Hello' }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const result = await rootAuthLoader(args, () => mockResponse); + + expect(result).toBeInstanceOf(Response); + const json = await result.json(); + expect(json).toHaveProperty('message', 'Hello'); + expect(json).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning data() format', async () => { + const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' })); + + expect(result).toBeInstanceOf(Response); + const response = result as unknown as Response; + expect(await response.json()).toHaveProperty('message', 'Hello from data()'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning plain object', async () => { + const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 5000)); + const plainObject = { message: 'Hello from plain object', nonCriticalData }; + + const result = await rootAuthLoader(args, () => plainObject); + + expect(result).toHaveProperty('message', 'Hello from plain object'); + expect(result).toHaveProperty('nonCriticalData', nonCriticalData); + expect(result).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + + it('should handle callback returning null', async () => { + const result = await rootAuthLoader(args, () => null); - it('should call legacyAuthenticateRequest when middleware context is missing', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(result).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + }); + }); + describe('without middleware context (legacy path)', () => { const mockContext = { get: vi.fn().mockReturnValue(null), }; @@ -56,12 +105,63 @@ describe('rootAuthLoader', () => { request: new Request('http://clerk.com'), } as LoaderFunctionArgs; - await rootAuthLoader(args, () => ({ data: 'test' })); + it('should call legacyAuthenticateRequest when middleware context is missing', async () => { + const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await rootAuthLoader(args, () => ({ data: 'test' })); + + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + expect(consoleWarnSpy).toHaveBeenCalledWith(middlewareMigrationWarning); + + consoleWarnSpy.mockRestore(); + }); + + it('should handle no callback in legacy mode', async () => { + const result = await rootAuthLoader(args); + + expect(result).toBeInstanceOf(Response); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); + + it('should handle callback returning Response in legacy mode', async () => { + const mockResponse = new Response(JSON.stringify({ message: 'Hello' })); + + const result = await rootAuthLoader(args, () => mockResponse); + + expect(result).toBeInstanceOf(Response); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); + + it('should handle callback returning data() format in legacy mode', async () => { + const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' })); + + expect(result).toBeInstanceOf(Response); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); + + it('should handle callback returning plain object in legacy mode', async () => { + const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 5000)); + const plainObject = { message: 'Hello from plain object', nonCriticalData }; + + const result = await rootAuthLoader(args, () => plainObject); - expect(legacyAuthenticateRequest).toHaveBeenCalled(); + expect(result).toBeInstanceOf(Response); + const response = result as unknown as Response; + const json = await response.json(); + expect(json).toHaveProperty('message', 'Hello from plain object'); + expect(json).toHaveProperty('nonCriticalData', {}); // serialized to {} + expect(json).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); - expect(consoleWarnSpy).toHaveBeenCalledWith(middlewareMigrationWarning); + it('should handle callback returning null in legacy mode', async () => { + const result = await rootAuthLoader(args, () => null); - consoleWarnSpy.mockRestore(); + expect(result).toBeInstanceOf(Response); + const response = result as unknown as Response; + const json = await response.json(); + expect(json).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); + }); }); }); diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts index 39173ef29cf..1fcee049e66 100644 --- a/packages/react-router/src/server/rootAuthLoader.ts +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -102,6 +102,7 @@ async function processRootAuthLoader( // Middleware path: return plain object with streaming support const { clerkState } = getResponseClerkState(requestState, args.context); + return { ...(handlerResult ?? {}), ...clerkState, From 464cb71b7549a6d9914cf23029a934eff6833fba Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 28 Aug 2025 17:05:23 -0700 Subject: [PATCH 03/35] chore: apply suggested fixes --- .../server/__tests__/rootAuthLoader.test.ts | 24 ++++++++++++------- .../src/server/clerkMiddleware.ts | 8 +++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index 5f9cdeb6f8f..45410d4f18c 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -1,4 +1,6 @@ +import { logger } from '@clerk/shared/logger'; import { data, type LoaderFunctionArgs } from 'react-router'; +import type { MockInstance } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { middlewareMigrationWarning } from '../../utils/errors'; @@ -35,14 +37,14 @@ describe('rootAuthLoader', () => { } as LoaderFunctionArgs; it('should not call legacyAuthenticateRequest when middleware context exists', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); await rootAuthLoader(args, () => ({ data: 'test' })); expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); - expect(consoleWarnSpy).not.toHaveBeenCalled(); + expect(warnOnceSpy).not.toHaveBeenCalled(); - consoleWarnSpy.mockRestore(); + warnOnceSpy.mockRestore(); }); it('should handle no callback - returns clerkState only', async () => { @@ -105,15 +107,21 @@ describe('rootAuthLoader', () => { request: new Request('http://clerk.com'), } as LoaderFunctionArgs; - it('should call legacyAuthenticateRequest when middleware context is missing', async () => { - const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let warnOnceSpy: MockInstance<(msg: string) => void>; + + beforeEach(() => { + warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + }); + afterEach(() => { + warnOnceSpy.mockRestore(); + }); + + it('should call legacyAuthenticateRequest when middleware context is missing', async () => { await rootAuthLoader(args, () => ({ data: 'test' })); expect(legacyAuthenticateRequest).toHaveBeenCalled(); - expect(consoleWarnSpy).toHaveBeenCalledWith(middlewareMigrationWarning); - - consoleWarnSpy.mockRestore(); + expect(warnOnceSpy).toHaveBeenCalledWith(middlewareMigrationWarning); }); it('should handle no callback in legacy mode', async () => { diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index cd69c09ad6a..db6447b6216 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -14,6 +14,14 @@ import { patchRequest } from './utils'; export const authFnContext = unstable_createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); export const requestStateContext = unstable_createContext | null>(null); +/** + * Middleware that integrates Clerk authentication into your React Router application. + * It checks the request's cookies and headers for a session JWT and, if found, + * attaches the Auth object to a context. + * + * @example + * export const middleware: Route.MiddlewareFunction[clerkMiddleware()] + */ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): unstable_MiddlewareFunction => { return async (args, next) => { const clerkRequest = createClerkRequest(patchRequest(args.request)); From 7e07dcaed6361e3c82f5d930dd69b685e14c9646 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 28 Aug 2025 17:11:42 -0700 Subject: [PATCH 04/35] test(react-router): Clean up rootAuthLoader unit tests --- .../server/__tests__/rootAuthLoader.test.ts | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index 45410d4f18c..8e57d33a65f 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -71,8 +71,9 @@ describe('rootAuthLoader', () => { it('should handle callback returning data() format', async () => { const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' })); - expect(result).toBeInstanceOf(Response); const response = result as unknown as Response; + + expect(result).toBeInstanceOf(Response); expect(await response.json()).toHaveProperty('message', 'Hello from data()'); expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); }); @@ -124,37 +125,45 @@ describe('rootAuthLoader', () => { expect(warnOnceSpy).toHaveBeenCalledWith(middlewareMigrationWarning); }); - it('should handle no callback in legacy mode', async () => { + it('should handle no callback', async () => { const result = await rootAuthLoader(args); + const response = result as Response; + expect(result).toBeInstanceOf(Response); + expect(await response.json()).toHaveProperty('clerkState'); expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); - it('should handle callback returning Response in legacy mode', async () => { + it('should handle callback returning Response', async () => { const mockResponse = new Response(JSON.stringify({ message: 'Hello' })); - const result = await rootAuthLoader(args, () => mockResponse); + const response = await rootAuthLoader(args, () => mockResponse); - expect(result).toBeInstanceOf(Response); + expect(response).toBeInstanceOf(Response); + expect(await response.json()).toHaveProperty('clerkState'); expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); - it('should handle callback returning data() format in legacy mode', async () => { + it('should handle callback returning data()', async () => { const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' })); - expect(result).toBeInstanceOf(Response); + const response = result as unknown as Response; + + expect(response).toBeInstanceOf(Response); + expect(await response.json()).toHaveProperty('clerkState'); expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); - it('should handle callback returning plain object in legacy mode', async () => { + it('should handle callback returning plain object', async () => { const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 5000)); const plainObject = { message: 'Hello from plain object', nonCriticalData }; const result = await rootAuthLoader(args, () => plainObject); - expect(result).toBeInstanceOf(Response); const response = result as unknown as Response; + + expect(result).toBeInstanceOf(Response); const json = await response.json(); expect(json).toHaveProperty('message', 'Hello from plain object'); expect(json).toHaveProperty('nonCriticalData', {}); // serialized to {} @@ -162,11 +171,12 @@ describe('rootAuthLoader', () => { expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); - it('should handle callback returning null in legacy mode', async () => { + it('should handle callback returning null', async () => { const result = await rootAuthLoader(args, () => null); - expect(result).toBeInstanceOf(Response); const response = result as unknown as Response; + + expect(result).toBeInstanceOf(Response); const json = await response.json(); expect(json).toHaveProperty('clerkState'); expect(legacyAuthenticateRequest).toHaveBeenCalled(); From b8f99982a4ba5c6cd53add8ef44bf2e1fde501d1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Thu, 28 Aug 2025 21:16:27 -0700 Subject: [PATCH 05/35] test streaming --- integration/templates/react-router-node/app/root.tsx | 2 +- integration/tests/react-router/basic.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 2ff3f1d91a9..8e28f2f1b05 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -7,7 +7,7 @@ import type { Route } from './+types/root'; export const unstable_middleware: Route.unstable_MiddlewareFunction[] = [clerkMiddleware()]; export async function loader(args: Route.LoaderArgs) { - const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 1000)); + const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 5000)); return rootAuthLoader(args, () => ({ nonCriticalData, diff --git a/integration/tests/react-router/basic.test.ts b/integration/tests/react-router/basic.test.ts index 526710c1c76..135b3fe92a3 100644 --- a/integration/tests/react-router/basic.test.ts +++ b/integration/tests/react-router/basic.test.ts @@ -89,15 +89,15 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: await expect(u.page.getByText(`Email: ${fakeUser.email}`)).toBeVisible(); }); - test.skip('streaming with Suspense works with rootAuthLoader', async ({ page, context }) => { + test('streaming with Suspense works with rootAuthLoader', async ({ page, context }) => { const u = createTestUtils({ app, page, context }); await u.page.goToRelative('/'); await expect(u.page.getByText('Loading...')).toBeVisible(); - // Wait for the streaming content to resolve - await expect(u.page.getByText('Non critical value: non-critical')).toBeVisible({ timeout: 3000 }); + // Wait for the streaming content to resolve (5 second delay + buffer) + await expect(u.page.getByText('Non critical value: non-critical')).toBeVisible({ timeout: 8000 }); await expect(u.page.getByText('Loading...')).toBeHidden(); }); }, From 328d3737f06813d89f1449499531f21772e058a8 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 29 Aug 2025 07:29:03 -0700 Subject: [PATCH 06/35] chore: update changeset --- .changeset/quiet-bats-protect.md | 46 +++++++++++++++++++ .../templates/react-router-node/app/root.tsx | 2 +- integration/tests/react-router/basic.test.ts | 5 +- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md index a845151cc84..a329dacc2c7 100644 --- a/.changeset/quiet-bats-protect.md +++ b/.changeset/quiet-bats-protect.md @@ -1,2 +1,48 @@ --- +'@clerk/react-router': major --- + +Introduce React Router middleware support with `clerkMiddleware()` for improved performance and streaming capabilities. + +Usage of `rootAuthLoader` without the `clerkMiddleware()` installed is now deprecated and will be removed in the next major version. + +**Before (Deprecated - will be removed):** + +```tsx +import { rootAuthLoader } from '@clerk/react-router/ssr.server' + +export async function loader(args: Route.LoaderArgs) { + return rootAuthLoader(args) +} +``` + +**After (Recommended):** +```tsx +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/ssr.server' + +export const middleware: Route.MiddlewareFunction[] = [ + clerkMiddleware() +] + +export async function loader(args: Route.LoaderArgs) { + return rootAuthLoader(args) +} +``` + +**Streaming Support (with middleware):** + +```tsx +export const middleware: Route.MiddlewareFunction[] = [ + clerkMiddleware() +] + +export async function loader(args: Route.LoaderArgs) { + const nonCriticalData = new Promise((res) => + setTimeout(() => res("non-critical"), 5000), + ) + + return rootAuthLoader(args, () => ({ + nonCriticalData + })) +} +``` \ No newline at end of file diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 8e28f2f1b05..30f03bb29cc 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -7,7 +7,7 @@ import type { Route } from './+types/root'; export const unstable_middleware: Route.unstable_MiddlewareFunction[] = [clerkMiddleware()]; export async function loader(args: Route.LoaderArgs) { - const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 5000)); + const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 10000)); return rootAuthLoader(args, () => ({ nonCriticalData, diff --git a/integration/tests/react-router/basic.test.ts b/integration/tests/react-router/basic.test.ts index 135b3fe92a3..eeecca28531 100644 --- a/integration/tests/react-router/basic.test.ts +++ b/integration/tests/react-router/basic.test.ts @@ -94,10 +94,9 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: await u.page.goToRelative('/'); - await expect(u.page.getByText('Loading...')).toBeVisible(); + await expect(u.page.getByText('Loading...')).toBeVisible({ timeout: 8000 }); - // Wait for the streaming content to resolve (5 second delay + buffer) - await expect(u.page.getByText('Non critical value: non-critical')).toBeVisible({ timeout: 8000 }); + await expect(u.page.getByText('Non critical value: non-critical')).toBeVisible(); await expect(u.page.getByText('Loading...')).toBeHidden(); }); }, From 73577de121a3bde2364eb08166c6f638fbb56882 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 29 Aug 2025 07:30:32 -0700 Subject: [PATCH 07/35] chore: update changeset --- .changeset/quiet-bats-protect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md index a329dacc2c7..78f52f7d166 100644 --- a/.changeset/quiet-bats-protect.md +++ b/.changeset/quiet-bats-protect.md @@ -2,7 +2,7 @@ '@clerk/react-router': major --- -Introduce React Router middleware support with `clerkMiddleware()` for improved performance and streaming capabilities. +Introduce [React Router middleware](https://reactrouter.com/how-to/middleware) support with `clerkMiddleware()` for improved performance and streaming capabilities. Usage of `rootAuthLoader` without the `clerkMiddleware()` installed is now deprecated and will be removed in the next major version. From be2d0b03f7f158e1153485a1a85e7a5492c68e13 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 29 Aug 2025 07:31:09 -0700 Subject: [PATCH 08/35] chore: update changeset --- .changeset/quiet-bats-protect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md index 78f52f7d166..416282e882b 100644 --- a/.changeset/quiet-bats-protect.md +++ b/.changeset/quiet-bats-protect.md @@ -18,7 +18,7 @@ export async function loader(args: Route.LoaderArgs) { **After (Recommended):** ```tsx -import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/ssr.server' +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' export const middleware: Route.MiddlewareFunction[] = [ clerkMiddleware() From 6ed6edd3e53e9fd96e082a793df2c06452d71610 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 30 Aug 2025 10:13:21 -0700 Subject: [PATCH 09/35] chore: improve unit tests --- .changeset/quiet-bats-protect.md | 20 ++-- .../templates/react-router-node/app/root.tsx | 14 +-- integration/tests/react-router/basic.test.ts | 11 --- .../src/server/__tests__/getAuth.test.ts | 30 ++++-- .../server/__tests__/rootAuthLoader.test.ts | 96 ++++++++++++++++--- 5 files changed, 112 insertions(+), 59 deletions(-) diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md index 416282e882b..f7fee7f1e5a 100644 --- a/.changeset/quiet-bats-protect.md +++ b/.changeset/quiet-bats-protect.md @@ -11,34 +11,26 @@ Usage of `rootAuthLoader` without the `clerkMiddleware()` installed is now depre ```tsx import { rootAuthLoader } from '@clerk/react-router/ssr.server' -export async function loader(args: Route.LoaderArgs) { - return rootAuthLoader(args) -} +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) ``` **After (Recommended):** ```tsx import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' -export const middleware: Route.MiddlewareFunction[] = [ - clerkMiddleware() -] +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] -export async function loader(args: Route.LoaderArgs) { - return rootAuthLoader(args) -} +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) ``` **Streaming Support (with middleware):** ```tsx -export const middleware: Route.MiddlewareFunction[] = [ - clerkMiddleware() -] +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] -export async function loader(args: Route.LoaderArgs) { +export const loader = (args: Route.LoaderArgs) => { const nonCriticalData = new Promise((res) => - setTimeout(() => res("non-critical"), 5000), + setTimeout(() => res('non-critical'), 5000), ) return rootAuthLoader(args, () => ({ diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 30f03bb29cc..3a1b81b0de0 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,18 +1,11 @@ -import * as React from 'react'; -import { Await, isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; +import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; export const unstable_middleware: Route.unstable_MiddlewareFunction[] = [clerkMiddleware()]; -export async function loader(args: Route.LoaderArgs) { - const nonCriticalData = new Promise(res => setTimeout(() => res('non-critical'), 10000)); - - return rootAuthLoader(args, () => ({ - nonCriticalData, - })); -} +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -40,9 +33,6 @@ export default function App({ loaderData }: Route.ComponentProps) {
- Loading...}> - {value =>

Non critical value: {value}

}
-
); diff --git a/integration/tests/react-router/basic.test.ts b/integration/tests/react-router/basic.test.ts index eeecca28531..e67921ef416 100644 --- a/integration/tests/react-router/basic.test.ts +++ b/integration/tests/react-router/basic.test.ts @@ -88,16 +88,5 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: await expect(u.page.getByText(`First name: ${fakeUser.firstName}`)).toBeVisible(); await expect(u.page.getByText(`Email: ${fakeUser.email}`)).toBeVisible(); }); - - test('streaming with Suspense works with rootAuthLoader', async ({ page, context }) => { - const u = createTestUtils({ app, page, context }); - - await u.page.goToRelative('/'); - - await expect(u.page.getByText('Loading...')).toBeVisible({ timeout: 8000 }); - - await expect(u.page.getByText('Non critical value: non-critical')).toBeVisible(); - await expect(u.page.getByText('Loading...')).toBeHidden(); - }); }, ); diff --git a/packages/react-router/src/server/__tests__/getAuth.test.ts b/packages/react-router/src/server/__tests__/getAuth.test.ts index c7311944fa8..742fc6f0ae1 100644 --- a/packages/react-router/src/server/__tests__/getAuth.test.ts +++ b/packages/react-router/src/server/__tests__/getAuth.test.ts @@ -1,13 +1,18 @@ +import { TokenType } from '@clerk/backend/internal'; import type { LoaderFunctionArgs } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { authFnContext } from '../clerkMiddleware'; import { getAuth } from '../getAuth'; import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; vi.mock('../legacyAuthenticateRequest', () => { return { legacyAuthenticateRequest: vi.fn().mockResolvedValue({ - toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx' }), + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), headers: new Headers(), status: 'signed-in', }), @@ -22,10 +27,17 @@ describe('getAuth', () => { it('should not call legacyAuthenticateRequest when middleware context exists', async () => { const mockContext = { - get: vi.fn().mockReturnValue((options?: any) => ({ - userId: 'user_xxx', - ...options, - })), + get: vi.fn().mockImplementation(contextKey => { + if (contextKey === authFnContext) { + return vi.fn().mockImplementation((options?: any) => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + ...options, + })); + } + return null; + }), + set: vi.fn(), }; const args = { @@ -33,9 +45,11 @@ describe('getAuth', () => { request: new Request('http://clerk.com'), } as LoaderFunctionArgs; - await getAuth(args); + const auth = await getAuth(args); expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); + expect(auth.userId).toBe('user_xxx'); + expect(auth.tokenType).toBe('session_token'); }); it('should call legacyAuthenticateRequest when middleware context is missing', async () => { @@ -48,8 +62,10 @@ describe('getAuth', () => { request: new Request('http://clerk.com'), } as LoaderFunctionArgs; - await getAuth(args); + const auth = await getAuth(args); expect(legacyAuthenticateRequest).toHaveBeenCalled(); + expect(auth.userId).toBe('user_xxx'); + expect(auth.tokenType).toBe('session_token'); }); }); diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index 8e57d33a65f..8707a24856a 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -1,17 +1,26 @@ +import { TokenType } from '@clerk/backend/internal'; import { logger } from '@clerk/shared/logger'; import { data, type LoaderFunctionArgs } from 'react-router'; import type { MockInstance } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { middlewareMigrationWarning } from '../../utils/errors'; +import { authFnContext, requestStateContext } from '../clerkMiddleware'; import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; import { rootAuthLoader } from '../rootAuthLoader'; vi.mock('../legacyAuthenticateRequest', () => { return { legacyAuthenticateRequest: vi.fn().mockResolvedValue({ - toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx' }), - headers: new Headers(), + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), + headers: new Headers({ + 'x-clerk-auth-status': 'signed-in', + 'x-clerk-auth-reason': 'auth-reason', + 'x-clerk-auth-message': 'auth-message', + }), status: 'signed-in', }), }; @@ -25,8 +34,25 @@ describe('rootAuthLoader', () => { describe('with middleware context', () => { const mockContext = { - get: vi.fn().mockReturnValue({ - toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx' }), + get: vi.fn().mockImplementation(contextKey => { + if (contextKey === requestStateContext) { + return { + toAuth: vi.fn().mockImplementation(() => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + })), + headers: new Headers(), + status: 'signed-in', + }; + } + if (contextKey === authFnContext) { + return vi.fn().mockImplementation((options?: any) => ({ + userId: 'user_xxx', + tokenType: TokenType.SessionToken, + ...options, + })); + } + return null; }), set: vi.fn(), }; @@ -47,7 +73,7 @@ describe('rootAuthLoader', () => { warnOnceSpy.mockRestore(); }); - it('should handle no callback - returns clerkState only', async () => { + it('should handle no callback', async () => { const result = await rootAuthLoader(args); expect(result).toHaveProperty('clerkState'); @@ -59,22 +85,36 @@ describe('rootAuthLoader', () => { headers: { 'Content-Type': 'application/json' }, }); - const result = await rootAuthLoader(args, () => mockResponse); + const response = await rootAuthLoader(args, () => mockResponse); - expect(result).toBeInstanceOf(Response); - const json = await result.json(); + expect(response).toBeInstanceOf(Response); + const json = await response.json(); expect(json).toHaveProperty('message', 'Hello'); expect(json).toHaveProperty('clerkState'); + + // Headers will be set by middleware + expect(response.headers.get('x-clerk-auth-reason')).toBeNull(); + expect(response.headers.get('x-clerk-auth-status')).toBeNull(); + expect(response.headers.get('x-clerk-auth-message')).toBeNull(); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); }); - it('should handle callback returning data() format', async () => { + it('should handle callback returning data()', async () => { const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' })); const response = result as unknown as Response; - expect(result).toBeInstanceOf(Response); - expect(await response.json()).toHaveProperty('message', 'Hello from data()'); + expect(response).toBeInstanceOf(Response); + const json = await response.json(); + expect(json).toHaveProperty('message', 'Hello from data()'); + expect(json).toHaveProperty('clerkState'); + + // Headers will be set by middleware + expect(response.headers.get('x-clerk-auth-reason')).toBeNull(); + expect(response.headers.get('x-clerk-auth-status')).toBeNull(); + expect(response.headers.get('x-clerk-auth-message')).toBeNull(); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); }); @@ -87,6 +127,7 @@ describe('rootAuthLoader', () => { expect(result).toHaveProperty('message', 'Hello from plain object'); expect(result).toHaveProperty('nonCriticalData', nonCriticalData); expect(result).toHaveProperty('clerkState'); + expect(legacyAuthenticateRequest).not.toHaveBeenCalled(); }); @@ -98,7 +139,7 @@ describe('rootAuthLoader', () => { }); }); - describe('without middleware context (legacy path)', () => { + describe('without middleware context', () => { const mockContext = { get: vi.fn().mockReturnValue(null), }; @@ -133,6 +174,10 @@ describe('rootAuthLoader', () => { expect(result).toBeInstanceOf(Response); expect(await response.json()).toHaveProperty('clerkState'); expect(legacyAuthenticateRequest).toHaveBeenCalled(); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); }); it('should handle callback returning Response', async () => { @@ -142,6 +187,11 @@ describe('rootAuthLoader', () => { expect(response).toBeInstanceOf(Response); expect(await response.json()).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); @@ -151,7 +201,14 @@ describe('rootAuthLoader', () => { const response = result as unknown as Response; expect(response).toBeInstanceOf(Response); - expect(await response.json()).toHaveProperty('clerkState'); + const json = await response.json(); + expect(json).toHaveProperty('message', 'Hello from data()'); + expect(json).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); @@ -168,6 +225,11 @@ describe('rootAuthLoader', () => { expect(json).toHaveProperty('message', 'Hello from plain object'); expect(json).toHaveProperty('nonCriticalData', {}); // serialized to {} expect(json).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); @@ -177,8 +239,12 @@ describe('rootAuthLoader', () => { const response = result as unknown as Response; expect(result).toBeInstanceOf(Response); - const json = await response.json(); - expect(json).toHaveProperty('clerkState'); + expect(await response.json()).toHaveProperty('clerkState'); + + expect(response.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(response.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(response.headers.get('x-clerk-auth-message')).toBe('auth-message'); + expect(legacyAuthenticateRequest).toHaveBeenCalled(); }); }); From db4ca90ad5611001ff697488c6f02877be1af0da Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 30 Aug 2025 10:31:17 -0700 Subject: [PATCH 10/35] chore: add clerkMiddleware unit test --- .../server/__tests__/clerkMiddleware.test.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/react-router/src/server/__tests__/clerkMiddleware.test.ts diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts new file mode 100644 index 00000000000..5c776373542 --- /dev/null +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts @@ -0,0 +1,150 @@ +import type { ClerkClient } from '@clerk/backend'; +import { AuthStatus, TokenType } from '@clerk/backend/internal'; +import type { LoaderFunctionArgs } from 'react-router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { clerkClient } from '../clerkClient'; +import { authFnContext, clerkMiddleware, requestStateContext } from '../clerkMiddleware'; +import { loadOptions } from '../loadOptions'; +import type { ClerkMiddlewareOptions } from '../types'; + +vi.mock('../clerkClient'); +vi.mock('../loadOptions'); + +const mockClerkClient = vi.mocked(clerkClient); +const mockLoadOptions = vi.mocked(loadOptions); + +describe('clerkMiddleware', () => { + const mockNext = vi.fn(); + const mockContext = { + set: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.CLERK_SECRET_KEY = 'sk_test_...'; + + mockLoadOptions.mockReturnValue({ + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + secretKey: 'sk_test_...', + publishableKey: 'pk_test_...', + } as unknown as ReturnType); + + mockClerkClient.mockReturnValue({ + authenticateRequest: vi.fn(), + } as unknown as ClerkClient); + }); + + it('should authenticate request and set context', async () => { + const mockRequestState = { + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }), + }; + + const mockAuthenticateRequest = vi.fn().mockResolvedValue(mockRequestState); + mockClerkClient.mockReturnValue({ + authenticateRequest: mockAuthenticateRequest, + } as unknown as ClerkClient); + + const middleware = clerkMiddleware(); + const args = { + request: new Request('http://clerk.com'), + context: mockContext, + } as LoaderFunctionArgs; + + const mockResponse = new Response('OK'); + mockNext.mockResolvedValue(mockResponse); + + const result = await middleware(args, mockNext); + + expect(mockAuthenticateRequest).toHaveBeenCalledWith(expect.any(Object), { + audience: '', + authorizedParties: [], + signInUrl: '', + signUpUrl: '', + afterSignInUrl: '', + afterSignUpUrl: '', + acceptsToken: 'any', + }); + + expect(mockContext.set).toHaveBeenCalledWith(authFnContext, expect.any(Function)); + expect(mockContext.set).toHaveBeenCalledWith(requestStateContext, mockRequestState); + + expect(mockNext).toHaveBeenCalled(); + + expect(result).toBe(mockResponse); + }); + + it('should pass options to loadOptions', async () => { + const mockRequestState = { + status: AuthStatus.SignedIn, + headers: new Headers(), + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }), + }; + + const mockAuthenticateRequest = vi.fn().mockResolvedValue(mockRequestState); + mockClerkClient.mockReturnValue({ + authenticateRequest: mockAuthenticateRequest, + } as unknown as ClerkClient); + + const options: ClerkMiddlewareOptions = { + audience: 'test-audience', + authorizedParties: ['https://example.com'], + signInUrl: '/sign-in', + signUpUrl: '/sign-up', + afterSignInUrl: '/dashboard', + afterSignUpUrl: '/welcome', + }; + + const middleware = clerkMiddleware(options); + const args = { + request: new Request('http://clerk.com'), + context: mockContext, + } as LoaderFunctionArgs; + + const mockResponse = new Response('OK'); + mockNext.mockResolvedValue(mockResponse); + + await middleware(args, mockNext); + + expect(mockLoadOptions).toHaveBeenCalledWith(args, options); + }); + + it('should append request state headers to response', async () => { + const mockRequestState = { + status: AuthStatus.SignedIn, + headers: new Headers({ + 'x-clerk-auth-status': 'signed-in', + 'x-clerk-auth-reason': 'auth-reason', + 'x-clerk-auth-message': 'auth-message', + }), + toAuth: vi.fn().mockReturnValue({ userId: 'user_xxx', tokenType: TokenType.SessionToken }), + }; + + const mockAuthenticateRequest = vi.fn().mockResolvedValue(mockRequestState); + mockClerkClient.mockReturnValue({ + authenticateRequest: mockAuthenticateRequest, + } as unknown as ClerkClient); + + const middleware = clerkMiddleware(); + const args = { + request: new Request('http://clerk.com'), + context: mockContext, + } as LoaderFunctionArgs; + + const mockResponse = new Response('OK'); + mockNext.mockResolvedValue(mockResponse); + + const result = (await middleware(args, mockNext)) as Response; + + expect(result.headers.get('x-clerk-auth-status')).toBe('signed-in'); + expect(result.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); + expect(result.headers.get('x-clerk-auth-message')).toBe('auth-message'); + }); +}); From 5df2647194e6eb03ba683233b7d8e0aff49c456b Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Sat, 30 Aug 2025 10:34:21 -0700 Subject: [PATCH 11/35] chore: clean up error message --- packages/react-router/src/utils/errors.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/utils/errors.ts b/packages/react-router/src/utils/errors.ts index 2fddeef28b4..80ed98102fc 100644 --- a/packages/react-router/src/utils/errors.ts +++ b/packages/react-router/src/utils/errors.ts @@ -99,14 +99,12 @@ const middlewareMigrationExample = `In the next major release, an error will be Example: -import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/ssr.server' +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' import { ClerkProvider } from '@clerk/react-router' export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] -export async function loader(args: Route.LoaderArgs) { - return rootAuthLoader(args) -} +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) export default function App({ loaderData }: Route.ComponentProps) { return ( From 73089663199034e97c90e4d30584495a90d6ee1d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Sep 2025 08:37:10 -0700 Subject: [PATCH 12/35] chore: Remove unstable prefix --- .../templates/react-router-library/package.json | 2 +- .../templates/react-router-node/app/root.tsx | 2 +- .../templates/react-router-node/package.json | 8 ++++---- .../react-router-node/react-router.config.ts | 3 --- packages/react-router/package.json | 4 ++-- packages/react-router/src/api/index.ts | 14 ++++++++++++++ .../react-router/src/server/clerkMiddleware.ts | 12 ++++++------ packages/react-router/src/server/loadOptions.ts | 4 ++-- packages/react-router/src/ssr/index.ts | 13 +++++++++++++ pnpm-lock.yaml | 10 +++++----- 10 files changed, 48 insertions(+), 24 deletions(-) diff --git a/integration/templates/react-router-library/package.json b/integration/templates/react-router-library/package.json index 48db058cc61..c159dec5df1 100644 --- a/integration/templates/react-router-library/package.json +++ b/integration/templates/react-router-library/package.json @@ -11,7 +11,7 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "^7.8.2" + "react-router": "^7.9.0" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 3a1b81b0de0..614a03814fe 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -3,7 +3,7 @@ import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; -export const unstable_middleware: Route.unstable_MiddlewareFunction[] = [clerkMiddleware()]; +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index cebb56d7da9..dcbfe9c96bb 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -9,15 +9,15 @@ "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { - "@react-router/node": "^7.8.2", - "@react-router/serve": "^7.8.2", + "@react-router/node": "^7.9.0", + "@react-router/serve": "^7.9.0", "isbot": "^5.1.17", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "^7.1.2" + "react-router": "^7.9.0" }, "devDependencies": { - "@react-router/dev": "^7.8.2", + "@react-router/dev": "^7.9.0", "@types/node": "^20", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/integration/templates/react-router-node/react-router.config.ts b/integration/templates/react-router-node/react-router.config.ts index 8f242de5639..4f9a6ed5228 100644 --- a/integration/templates/react-router-node/react-router.config.ts +++ b/integration/templates/react-router-node/react-router.config.ts @@ -4,7 +4,4 @@ export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` ssr: true, - future: { - unstable_middleware: true, - }, } satisfies Config; diff --git a/packages/react-router/package.json b/packages/react-router/package.json index f4f2fb8482c..514ecb33ad2 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -99,12 +99,12 @@ "devDependencies": { "@types/cookie": "^0.6.0", "esbuild-plugin-file-path-extensions": "^2.1.4", - "react-router": "7.8.2" + "react-router": "7.9.0" }, "peerDependencies": { "react": "catalog:peer-react", "react-dom": "catalog:peer-react", - "react-router": "^7.8.2" + "react-router": "^7.9.0" }, "engines": { "node": ">=20.0.0" diff --git a/packages/react-router/src/api/index.ts b/packages/react-router/src/api/index.ts index f5ce35a683b..cb3b0378663 100644 --- a/packages/react-router/src/api/index.ts +++ b/packages/react-router/src/api/index.ts @@ -1 +1,15 @@ export * from '@clerk/backend'; + +import { logger } from '@clerk/shared/logger'; + +logger.warnOnce(` +Clerk - DEPRECATION WARNING: \`@clerk/react-router/api.server\` has been deprecated and will be removed in the next major version. + +Import from \`@clerk/react-router/server\` instead. + +Before: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/api.server'; + +After: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +`); diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index db6447b6216..39b68a6c9a9 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -3,16 +3,16 @@ import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; import type { PendingSessionOptions } from '@clerk/types'; -import type { unstable_MiddlewareFunction } from 'react-router'; -import { unstable_createContext } from 'react-router'; +import type { MiddlewareFunction } from 'react-router'; +import { createContext } from 'react-router'; import { clerkClient } from './clerkClient'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; -export const authFnContext = unstable_createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); -export const requestStateContext = unstable_createContext | null>(null); +export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); +export const requestStateContext = createContext | null>(null); /** * Middleware that integrates Clerk authentication into your React Router application. @@ -20,9 +20,9 @@ export const requestStateContext = unstable_createContext | nu * attaches the Auth object to a context. * * @example - * export const middleware: Route.MiddlewareFunction[clerkMiddleware()] + * export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] */ -export const clerkMiddleware = (options?: ClerkMiddlewareOptions): unstable_MiddlewareFunction => { +export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { return async (args, next) => { const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); diff --git a/packages/react-router/src/server/loadOptions.ts b/packages/react-router/src/server/loadOptions.ts index f2710c8ebbe..6c64a7face8 100644 --- a/packages/react-router/src/server/loadOptions.ts +++ b/packages/react-router/src/server/loadOptions.ts @@ -4,14 +4,14 @@ import { getEnvVariable } from '@clerk/shared/getEnvVariable'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; import { handleValueOrFn } from '@clerk/shared/utils'; -import type { unstable_MiddlewareFunction } from 'react-router'; +import type { MiddlewareFunction } from 'react-router'; import { getPublicEnvVariables } from '../utils/env'; import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from '../utils/errors'; import type { ClerkMiddlewareOptions } from './types'; import { patchRequest } from './utils'; -export type DataFunctionArgs = Parameters>[0]; +export type DataFunctionArgs = Parameters>[0]; export const loadOptions = (args: DataFunctionArgs, overrides: ClerkMiddlewareOptions = {}) => { // see https://developers.cloudflare.com/workers/framework-guides/web-apps/react-router/#use-bindings-with-react-router diff --git a/packages/react-router/src/ssr/index.ts b/packages/react-router/src/ssr/index.ts index aa8235f0eed..28e761a5b0f 100644 --- a/packages/react-router/src/ssr/index.ts +++ b/packages/react-router/src/ssr/index.ts @@ -1,5 +1,18 @@ export { rootAuthLoader } from '../server/rootAuthLoader'; export { getAuth } from '../server/getAuth'; +import { logger } from '@clerk/shared/logger'; + +logger.warnOnce(` +Clerk - DEPRECATION WARNING: \`@clerk/react-router/ssr.server\` has been deprecated and will be removed in the next major version. + +Import from \`@clerk/react-router/server\` instead. + +Before: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/ssr.server'; + +After: + import { getAuth, clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +`); /** * Re-export resource types from @clerk/backend diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a29e77b26e7..138edf5f60d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -877,8 +877,8 @@ importers: specifier: ^2.1.4 version: 2.1.4 react-router: - specifier: 7.8.2 - version: 7.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.9.0 + version: 7.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) packages/remix: dependencies: @@ -12390,8 +12390,8 @@ packages: peerDependencies: react: '>=16.8' - react-router@7.8.2: - resolution: {integrity: sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==} + react-router@7.9.0: + resolution: {integrity: sha512-gmmc2UNj8oS8Z2JGpfAmhLv+j5O9Xciv2HAGZN0rV//ycoe1E40xN3ovqLZD7PsMDkoJvsbASE8TjAY+Xm7DKQ==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -29364,7 +29364,7 @@ snapshots: '@remix-run/router': 1.23.0 react: 18.3.1 - react-router@7.8.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router@7.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: cookie: 1.0.2 react: 18.3.1 From d8aa016813b7978a115564f8d9ecd1f791ac97b3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Fri, 12 Sep 2025 09:08:00 -0700 Subject: [PATCH 13/35] chore: add v8_middleware flag --- integration/templates/react-router-node/react-router.config.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/integration/templates/react-router-node/react-router.config.ts b/integration/templates/react-router-node/react-router.config.ts index 4f9a6ed5228..b01bcc0cf09 100644 --- a/integration/templates/react-router-node/react-router.config.ts +++ b/integration/templates/react-router-node/react-router.config.ts @@ -4,4 +4,7 @@ export default { // Config options... // Server-side render by default, to enable SPA mode set this to `false` ssr: true, + future: { + v8_middleware: true, + }, } satisfies Config; From 6d105a156a6d89356c6d3446137a55a8e71508e0 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 15 Sep 2025 10:25:51 -0700 Subject: [PATCH 14/35] chore: make sure v8_middleware flag is enabled --- packages/react-router/src/server/clerkMiddleware.ts | 7 ++++++- packages/react-router/src/server/getAuth.ts | 3 ++- packages/react-router/src/server/rootAuthLoader.ts | 5 +++-- packages/react-router/src/server/utils.ts | 8 ++++++++ packages/react-router/src/utils/errors.ts | 6 ++++++ 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 39b68a6c9a9..b207d8428d6 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -6,10 +6,11 @@ import type { PendingSessionOptions } from '@clerk/types'; import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; +import { v8MiddlewareFlagError } from '../utils/errors'; import { clerkClient } from './clerkClient'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions } from './types'; -import { patchRequest } from './utils'; +import { IsOptIntoMiddleware, patchRequest } from './utils'; export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); export const requestStateContext = createContext | null>(null); @@ -24,6 +25,10 @@ export const requestStateContext = createContext | null>(null) */ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { return async (args, next) => { + if (!IsOptIntoMiddleware(args.context)) { + throw new Error(v8MiddlewareFlagError); + } + const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); const { audience, authorizedParties } = loadedOptions; diff --git a/packages/react-router/src/server/getAuth.ts b/packages/react-router/src/server/getAuth.ts index 07f879038b6..0c3acbb171a 100644 --- a/packages/react-router/src/server/getAuth.ts +++ b/packages/react-router/src/server/getAuth.ts @@ -6,6 +6,7 @@ import { import type { PendingSessionOptions } from '@clerk/types'; import type { LoaderFunctionArgs } from 'react-router'; +import { IsOptIntoMiddleware } from '../server/utils'; import { noLoaderArgsPassedInGetAuth } from '../utils/errors'; import { authFnContext } from './clerkMiddleware'; import { legacyAuthenticateRequest } from './legacyAuthenticateRequest'; @@ -24,7 +25,7 @@ export const getAuth: GetAuthFn = (async ( const { acceptsToken, treatPendingAsSignedOut, ...restOptions } = opts || {}; // If the middleware is installed, use the auth function from the context - const authObjectFn = args.context.get(authFnContext); + const authObjectFn = IsOptIntoMiddleware(args.context) && args.context.get(authFnContext); if (authObjectFn) { return getAuthObjectForAcceptedToken({ authObject: authObjectFn({ treatPendingAsSignedOut }), diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts index 1fcee049e66..a7fed957ad8 100644 --- a/packages/react-router/src/server/rootAuthLoader.ts +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -17,6 +17,7 @@ import { getResponseClerkState, injectRequestStateIntoResponse, isDataWithResponseInit, + IsOptIntoMiddleware, isRedirect, isResponse, } from './utils'; @@ -45,7 +46,7 @@ async function processRootAuthLoader( requestState: RequestState, handler?: RootAuthLoaderCallback, ): Promise { - const hasMiddleware = !!args.context.get(authFnContext); + const hasMiddleware = IsOptIntoMiddleware(args.context) && !!args.context.get(authFnContext); const includeClerkHeaders = !hasMiddleware; if (!handler) { @@ -126,7 +127,7 @@ export const rootAuthLoader: RootAuthLoader = async ( ? handlerOrOptions : {}; - const requestState = args.context.get(requestStateContext); + const requestState = IsOptIntoMiddleware(args.context) && args.context.get(requestStateContext); if (!requestState) { logger.warnOnce(middlewareMigrationWarning); diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index 599a8efd780..6b5552da0d6 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -40,6 +40,14 @@ export function assertValidHandlerResult(val: any, error?: string): asserts val } } +/** + * `get` and `set` properties will only be available if v8_middleware flag is enabled + * See: https://reactrouter.com/upgrading/future#futurev8_middleware + */ +export const IsOptIntoMiddleware = (context: AppLoadContext) => { + return 'get' in context && 'set' in context; +}; + export const injectRequestStateIntoResponse = async ( response: Response, requestState: RequestStateWithRedirectUrls, diff --git a/packages/react-router/src/utils/errors.ts b/packages/react-router/src/utils/errors.ts index 80ed98102fc..273d828ddb7 100644 --- a/packages/react-router/src/utils/errors.ts +++ b/packages/react-router/src/utils/errors.ts @@ -120,3 +120,9 @@ export const middlewareMigrationWarning = createErrorMessage(` ${middlewareMigrationExample} `); + +export const v8MiddlewareFlagError = createErrorMessage(` +Enable the 'v8_middleware' future flag to start using the clerkMiddleware middleware. + +For more details, see: https://reactrouter.com/upgrading/future#futurev8_middleware +`); From e95ad51fec9afda253442d77178baf1666b5a07e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 15 Sep 2025 10:27:37 -0700 Subject: [PATCH 15/35] chore: update changeset --- .changeset/quiet-bats-protect.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md index f7fee7f1e5a..73f732b19fe 100644 --- a/.changeset/quiet-bats-protect.md +++ b/.changeset/quiet-bats-protect.md @@ -15,6 +15,20 @@ export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args) ``` **After (Recommended):** + +1. Enable the `v8_middleware` future flag: + +```ts +// react-router.config.ts +export default { + future: { + v8_middleware: true, + }, +} satisfies Config; +``` + +2. Use the middleware in your app: + ```tsx import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' From ff004d0ab37aca08581eeaa77bcc26e3b379bb25 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 15 Sep 2025 10:29:43 -0700 Subject: [PATCH 16/35] chore: update middleware JSDoc --- packages/react-router/src/server/clerkMiddleware.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index b207d8428d6..07b559b084f 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -20,7 +20,17 @@ export const requestStateContext = createContext | null>(null) * It checks the request's cookies and headers for a session JWT and, if found, * attaches the Auth object to a context. * + * @requires The `v8_middleware` future flag must be enabled in your config + * * @example + * // react-router.config.ts + * export default { + * future: { + * v8_middleware: true, + * }, + * } + * + * // app.tsx * export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] */ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { From 982f92807e886d1a20146bc1a17f19fdf385b1d0 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 15 Sep 2025 10:58:58 -0700 Subject: [PATCH 17/35] fix tests --- .../__tests__/__snapshots__/exports.test.ts.snap | 12 +++++++++++- .../react-router/src/__tests__/exports.test.ts | 9 ++++++++- .../src/server/__tests__/clerkMiddleware.test.ts | 11 +++++++++++ .../src/server/__tests__/rootAuthLoader.test.ts | 6 +++--- .../react-router/src/server/clerkMiddleware.ts | 2 +- .../react-router/src/server/rootAuthLoader.ts | 16 +++++++++++++--- packages/react-router/src/utils/errors.ts | 6 ++++++ 7 files changed, 53 insertions(+), 9 deletions(-) diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 440ab515d9f..79d2316cc67 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -1,5 +1,12 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html +exports[`deprecated ssr public exports > should not change unexpectedly 1`] = ` +[ + "getAuth", + "rootAuthLoader", +] +`; + exports[`root public exports > should not change unexpectedly 1`] = ` [ "APIKeys", @@ -53,9 +60,12 @@ exports[`root public exports > should not change unexpectedly 1`] = ` ] `; -exports[`ssr public exports > should not change unexpectedly 1`] = ` +exports[`server public exports > should not change unexpectedly 1`] = ` [ + "clerkMiddleware", + "createClerkClient", "getAuth", "rootAuthLoader", + "verifyToken", ] `; diff --git a/packages/react-router/src/__tests__/exports.test.ts b/packages/react-router/src/__tests__/exports.test.ts index 4bba4ec2277..17fc6b53346 100644 --- a/packages/react-router/src/__tests__/exports.test.ts +++ b/packages/react-router/src/__tests__/exports.test.ts @@ -1,4 +1,5 @@ import * as publicExports from '../index'; +import * as serverExports from '../server/index'; import * as ssrExports from '../ssr/index'; describe('root public exports', () => { @@ -7,7 +8,13 @@ describe('root public exports', () => { }); }); -describe('ssr public exports', () => { +describe('server public exports', () => { + it('should not change unexpectedly', () => { + expect(Object.keys(serverExports).sort()).toMatchSnapshot(); + }); +}); + +describe('deprecated ssr public exports', () => { it('should not change unexpectedly', () => { expect(Object.keys(ssrExports).sort()).toMatchSnapshot(); }); diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts index 5c776373542..b13b14c3370 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts @@ -17,6 +17,7 @@ const mockLoadOptions = vi.mocked(loadOptions); describe('clerkMiddleware', () => { const mockNext = vi.fn(); const mockContext = { + get: vi.fn(), set: vi.fn(), }; @@ -147,4 +148,14 @@ describe('clerkMiddleware', () => { expect(result.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); expect(result.headers.get('x-clerk-auth-message')).toBe('auth-message'); }); + + it('should throw error when v8_middleware flag is not enabled', async () => { + const middleware = clerkMiddleware(); + const args = { + request: new Request('http://clerk.com'), + context: {}, // Context without 'get' and 'set' methods + } as LoaderFunctionArgs; + + await expect(middleware(args, mockNext)).rejects.toThrow("Enable the 'v8_middleware' future flag"); + }); }); diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index 8707a24856a..38f3a9f3997 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -4,7 +4,7 @@ import { data, type LoaderFunctionArgs } from 'react-router'; import type { MockInstance } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { middlewareMigrationWarning } from '../../utils/errors'; +import { v8MiddlewareFlagRequiredWarning } from '../../utils/errors'; import { authFnContext, requestStateContext } from '../clerkMiddleware'; import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; import { rootAuthLoader } from '../rootAuthLoader'; @@ -141,7 +141,7 @@ describe('rootAuthLoader', () => { describe('without middleware context', () => { const mockContext = { - get: vi.fn().mockReturnValue(null), + // No get/set methods - simulates v8_middleware flag not enabled }; const args = { @@ -163,7 +163,7 @@ describe('rootAuthLoader', () => { await rootAuthLoader(args, () => ({ data: 'test' })); expect(legacyAuthenticateRequest).toHaveBeenCalled(); - expect(warnOnceSpy).toHaveBeenCalledWith(middlewareMigrationWarning); + expect(warnOnceSpy).toHaveBeenCalledWith(v8MiddlewareFlagRequiredWarning); }); it('should handle no callback', async () => { diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 07b559b084f..f971a7bc7f5 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -30,7 +30,7 @@ export const requestStateContext = createContext | null>(null) * }, * } * - * // app.tsx + * // root.tsx * export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] */ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts index a7fed957ad8..4dc709c7aa1 100644 --- a/packages/react-router/src/server/rootAuthLoader.ts +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -3,7 +3,11 @@ import { decorateObjectWithResources } from '@clerk/backend/internal'; import { logger } from '@clerk/shared/logger'; import type { LoaderFunctionArgs } from 'react-router'; -import { invalidRootLoaderCallbackReturn, middlewareMigrationWarning } from '../utils/errors'; +import { + invalidRootLoaderCallbackReturn, + middlewareMigrationWarning, + v8MiddlewareFlagRequiredWarning, +} from '../utils/errors'; import { authFnContext, requestStateContext } from './clerkMiddleware'; import { legacyAuthenticateRequest } from './legacyAuthenticateRequest'; import { loadOptions } from './loadOptions'; @@ -127,10 +131,16 @@ export const rootAuthLoader: RootAuthLoader = async ( ? handlerOrOptions : {}; - const requestState = IsOptIntoMiddleware(args.context) && args.context.get(requestStateContext); + const hasMiddlewareFlag = IsOptIntoMiddleware(args.context); + const requestState = hasMiddlewareFlag && args.context.get(requestStateContext); if (!requestState) { - logger.warnOnce(middlewareMigrationWarning); + // Check if v8_middleware flag is not enabled first + if (!hasMiddlewareFlag) { + logger.warnOnce(v8MiddlewareFlagRequiredWarning); + } else { + logger.warnOnce(middlewareMigrationWarning); + } return legacyRootAuthLoader(args, handlerOrOptions, opts); } diff --git a/packages/react-router/src/utils/errors.ts b/packages/react-router/src/utils/errors.ts index 273d828ddb7..9d74a6252fc 100644 --- a/packages/react-router/src/utils/errors.ts +++ b/packages/react-router/src/utils/errors.ts @@ -121,6 +121,12 @@ export const middlewareMigrationWarning = createErrorMessage(` ${middlewareMigrationExample} `); +export const v8MiddlewareFlagRequiredWarning = createErrorMessage(` +The 'v8_middleware' future flag is required to use the new middleware system. + +${middlewareMigrationExample} +`); + export const v8MiddlewareFlagError = createErrorMessage(` Enable the 'v8_middleware' future flag to start using the clerkMiddleware middleware. From e049ba816733fd3fffed61926b76060f627fe3a9 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Mon, 15 Sep 2025 11:12:48 -0700 Subject: [PATCH 18/35] fix tests --- .../src/__tests__/exports.test.ts | 10 ++++++-- .../server/__tests__/clerkMiddleware.test.ts | 10 -------- .../server/__tests__/rootAuthLoader.test.ts | 4 +-- .../src/server/clerkMiddleware.ts | 9 +------ .../react-router/src/server/rootAuthLoader.ts | 13 ++-------- packages/react-router/src/utils/errors.ts | 25 ++++++++----------- 6 files changed, 24 insertions(+), 47 deletions(-) diff --git a/packages/react-router/src/__tests__/exports.test.ts b/packages/react-router/src/__tests__/exports.test.ts index 17fc6b53346..1d9551d96c7 100644 --- a/packages/react-router/src/__tests__/exports.test.ts +++ b/packages/react-router/src/__tests__/exports.test.ts @@ -1,6 +1,8 @@ +import { logger } from '@clerk/shared/logger'; +import { vi } from 'vitest'; + import * as publicExports from '../index'; import * as serverExports from '../server/index'; -import * as ssrExports from '../ssr/index'; describe('root public exports', () => { it('should not change unexpectedly', () => { @@ -15,7 +17,11 @@ describe('server public exports', () => { }); describe('deprecated ssr public exports', () => { - it('should not change unexpectedly', () => { + it('should not change unexpectedly', async () => { + const warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {}); + const ssrExports = await import('../ssr/index'); expect(Object.keys(ssrExports).sort()).toMatchSnapshot(); + expect(warnOnceSpy).toHaveBeenCalled(); + warnOnceSpy.mockRestore(); }); }); diff --git a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts index b13b14c3370..8be8e33c419 100644 --- a/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts +++ b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts @@ -148,14 +148,4 @@ describe('clerkMiddleware', () => { expect(result.headers.get('x-clerk-auth-reason')).toBe('auth-reason'); expect(result.headers.get('x-clerk-auth-message')).toBe('auth-message'); }); - - it('should throw error when v8_middleware flag is not enabled', async () => { - const middleware = clerkMiddleware(); - const args = { - request: new Request('http://clerk.com'), - context: {}, // Context without 'get' and 'set' methods - } as LoaderFunctionArgs; - - await expect(middleware(args, mockNext)).rejects.toThrow("Enable the 'v8_middleware' future flag"); - }); }); diff --git a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts index 38f3a9f3997..e8bc86e116b 100644 --- a/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts +++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts @@ -4,7 +4,7 @@ import { data, type LoaderFunctionArgs } from 'react-router'; import type { MockInstance } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { v8MiddlewareFlagRequiredWarning } from '../../utils/errors'; +import { middlewareMigrationWarning } from '../../utils/errors'; import { authFnContext, requestStateContext } from '../clerkMiddleware'; import { legacyAuthenticateRequest } from '../legacyAuthenticateRequest'; import { rootAuthLoader } from '../rootAuthLoader'; @@ -163,7 +163,7 @@ describe('rootAuthLoader', () => { await rootAuthLoader(args, () => ({ data: 'test' })); expect(legacyAuthenticateRequest).toHaveBeenCalled(); - expect(warnOnceSpy).toHaveBeenCalledWith(v8MiddlewareFlagRequiredWarning); + expect(warnOnceSpy).toHaveBeenCalledWith(middlewareMigrationWarning); }); it('should handle no callback', async () => { diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index f971a7bc7f5..458fb7c7bf4 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -6,11 +6,10 @@ import type { PendingSessionOptions } from '@clerk/types'; import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; -import { v8MiddlewareFlagError } from '../utils/errors'; import { clerkClient } from './clerkClient'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions } from './types'; -import { IsOptIntoMiddleware, patchRequest } from './utils'; +import { patchRequest } from './utils'; export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null); export const requestStateContext = createContext | null>(null); @@ -20,8 +19,6 @@ export const requestStateContext = createContext | null>(null) * It checks the request's cookies and headers for a session JWT and, if found, * attaches the Auth object to a context. * - * @requires The `v8_middleware` future flag must be enabled in your config - * * @example * // react-router.config.ts * export default { @@ -35,10 +32,6 @@ export const requestStateContext = createContext | null>(null) */ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFunction => { return async (args, next) => { - if (!IsOptIntoMiddleware(args.context)) { - throw new Error(v8MiddlewareFlagError); - } - const clerkRequest = createClerkRequest(patchRequest(args.request)); const loadedOptions = loadOptions(args, options); const { audience, authorizedParties } = loadedOptions; diff --git a/packages/react-router/src/server/rootAuthLoader.ts b/packages/react-router/src/server/rootAuthLoader.ts index 4dc709c7aa1..b3e108ca85b 100644 --- a/packages/react-router/src/server/rootAuthLoader.ts +++ b/packages/react-router/src/server/rootAuthLoader.ts @@ -3,11 +3,7 @@ import { decorateObjectWithResources } from '@clerk/backend/internal'; import { logger } from '@clerk/shared/logger'; import type { LoaderFunctionArgs } from 'react-router'; -import { - invalidRootLoaderCallbackReturn, - middlewareMigrationWarning, - v8MiddlewareFlagRequiredWarning, -} from '../utils/errors'; +import { invalidRootLoaderCallbackReturn, middlewareMigrationWarning } from '../utils/errors'; import { authFnContext, requestStateContext } from './clerkMiddleware'; import { legacyAuthenticateRequest } from './legacyAuthenticateRequest'; import { loadOptions } from './loadOptions'; @@ -135,12 +131,7 @@ export const rootAuthLoader: RootAuthLoader = async ( const requestState = hasMiddlewareFlag && args.context.get(requestStateContext); if (!requestState) { - // Check if v8_middleware flag is not enabled first - if (!hasMiddlewareFlag) { - logger.warnOnce(v8MiddlewareFlagRequiredWarning); - } else { - logger.warnOnce(middlewareMigrationWarning); - } + logger.warnOnce(middlewareMigrationWarning); return legacyRootAuthLoader(args, handlerOrOptions, opts); } diff --git a/packages/react-router/src/utils/errors.ts b/packages/react-router/src/utils/errors.ts index 9d74a6252fc..be88c9880ae 100644 --- a/packages/react-router/src/utils/errors.ts +++ b/packages/react-router/src/utils/errors.ts @@ -95,9 +95,18 @@ Example: `); -const middlewareMigrationExample = `In the next major release, an error will be thrown if the middleware is not installed. +const middlewareMigrationExample = `To use the new middleware system, you need to: -Example: +1. Enable the 'v8_middleware' future flag in your config: + +// react-router.config.ts +export default { + future: { + v8_middleware: true, + }, +} satisfies Config; + +2. Install the clerkMiddleware: import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server' import { ClerkProvider } from '@clerk/react-router' @@ -120,15 +129,3 @@ export const middlewareMigrationWarning = createErrorMessage(` ${middlewareMigrationExample} `); - -export const v8MiddlewareFlagRequiredWarning = createErrorMessage(` -The 'v8_middleware' future flag is required to use the new middleware system. - -${middlewareMigrationExample} -`); - -export const v8MiddlewareFlagError = createErrorMessage(` -Enable the 'v8_middleware' future flag to start using the clerkMiddleware middleware. - -For more details, see: https://reactrouter.com/upgrading/future#futurev8_middleware -`); From 14450a396743b7d1446386fb0cbb9761b8b9ab02 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 11:48:22 -0700 Subject: [PATCH 19/35] pin versions --- .../react-router-library/package.json | 2 +- .../app/routes/protected.tsx | 14 ++++++------- .../templates/react-router-node/package.json | 20 +++++++++---------- packages/react-router/package.json | 2 +- .../__snapshots__/exports.test.ts.snap | 1 + packages/react-router/src/server/index.ts | 1 + 6 files changed, 21 insertions(+), 19 deletions(-) diff --git a/integration/templates/react-router-library/package.json b/integration/templates/react-router-library/package.json index c159dec5df1..41fb0cc716e 100644 --- a/integration/templates/react-router-library/package.json +++ b/integration/templates/react-router-library/package.json @@ -11,7 +11,7 @@ "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router": "^7.9.0" + "react-router": "7.9.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/integration/templates/react-router-node/app/routes/protected.tsx b/integration/templates/react-router-node/app/routes/protected.tsx index 2fdc2718e1c..5b01b817505 100644 --- a/integration/templates/react-router-node/app/routes/protected.tsx +++ b/integration/templates/react-router-node/app/routes/protected.tsx @@ -1,8 +1,7 @@ import { redirect } from 'react-router'; import { UserProfile } from '@clerk/react-router'; -import { getAuth } from '@clerk/react-router/ssr.server'; -import { createClerkClient } from '@clerk/react-router/api.server'; -import type { Route } from './+types/profile'; +import { clerkClient, getAuth } from '@clerk/react-router/server'; +import type { Route } from './+types/protected'; export async function loader(args: Route.LoaderArgs) { const { userId } = await getAuth(args); @@ -11,10 +10,11 @@ export async function loader(args: Route.LoaderArgs) { return redirect('/sign-in'); } - const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId); + const user = await clerkClient(args).users.getUser(userId); return { - user, + firstName: user.firstName, + emailAddress: user.emailAddresses[0].emailAddress, }; } @@ -24,8 +24,8 @@ export default function Profile({ loaderData }: Route.ComponentProps) {

Protected

    -
  • First name: {loaderData.user.firstName}
  • -
  • Email: {loaderData.user.emailAddresses[0].emailAddress}
  • +
  • First name: {loaderData.firstName}
  • +
  • Email: {loaderData.emailAddress}
); diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index dcbfe9c96bb..dbe803b613f 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -9,20 +9,20 @@ "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { - "@react-router/node": "^7.9.0", - "@react-router/serve": "^7.9.0", + "@react-router/node": "7.9.1", + "@react-router/serve": "7.9.1", "isbot": "^5.1.17", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router": "^7.9.0" + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "7.9.1" }, "devDependencies": { - "@react-router/dev": "^7.9.0", + "@react-router/dev": "7.9.1", "@types/node": "^20", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", "typescript": "^5.7.3", - "vite": "^5.4.11", - "vite-tsconfig-paths": "^5.1.2" + "vite": "^6.3.3", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 514ecb33ad2..ecf623b6603 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -99,7 +99,7 @@ "devDependencies": { "@types/cookie": "^0.6.0", "esbuild-plugin-file-path-extensions": "^2.1.4", - "react-router": "7.9.0" + "react-router": "7.9.1" }, "peerDependencies": { "react": "catalog:peer-react", diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index 79d2316cc67..4d86e299ac4 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -62,6 +62,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` exports[`server public exports > should not change unexpectedly 1`] = ` [ + "clerkClient", "clerkMiddleware", "createClerkClient", "getAuth", diff --git a/packages/react-router/src/server/index.ts b/packages/react-router/src/server/index.ts index 0e85b3f56f0..fb78cd1fed7 100644 --- a/packages/react-router/src/server/index.ts +++ b/packages/react-router/src/server/index.ts @@ -2,3 +2,4 @@ export * from '@clerk/backend'; export { clerkMiddleware } from './clerkMiddleware'; export { rootAuthLoader } from './rootAuthLoader'; export { getAuth } from './getAuth'; +export { clerkClient } from './clerkClient'; From 3d5871e0e96bfe61c7cb99a8833893b16ff720ec Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 11:49:14 -0700 Subject: [PATCH 20/35] chore: run dedupe --- pnpm-lock.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 138edf5f60d..d7d3ca2ad73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -877,8 +877,8 @@ importers: specifier: ^2.1.4 version: 2.1.4 react-router: - specifier: 7.9.0 - version: 7.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: 7.9.1 + version: 7.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) packages/remix: dependencies: @@ -2917,7 +2917,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {'0': node >=0.10.0} + engines: {node: '>=0.10.0'} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -12390,8 +12390,8 @@ packages: peerDependencies: react: '>=16.8' - react-router@7.9.0: - resolution: {integrity: sha512-gmmc2UNj8oS8Z2JGpfAmhLv+j5O9Xciv2HAGZN0rV//ycoe1E40xN3ovqLZD7PsMDkoJvsbASE8TjAY+Xm7DKQ==} + react-router@7.9.1: + resolution: {integrity: sha512-pfAByjcTpX55mqSDGwGnY9vDCpxqBLASg0BMNAuMmpSGESo/TaOUG6BllhAtAkCGx8Rnohik/XtaqiYUJtgW2g==} engines: {node: '>=20.0.0'} peerDependencies: react: '>=18' @@ -14523,6 +14523,7 @@ packages: vitest-environment-miniflare@2.14.4: resolution: {integrity: sha512-DzwQWdY42sVYR6aUndw9FdCtl/i0oh3NkbkQpw+xq5aYQw5eiJn5kwnKaKQEWaoBe8Cso71X2i1EJGvi1jZ2xw==} engines: {node: '>=16.13'} + deprecated: Miniflare v2 is no longer supported. Please upgrade to Miniflare v4 peerDependencies: vitest: '>=0.23.0' @@ -29364,7 +29365,7 @@ snapshots: '@remix-run/router': 1.23.0 react: 18.3.1 - react-router@7.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router@7.9.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: cookie: 1.0.2 react: 18.3.1 From 5f79582bd1423bb54e117636220a4a7c3e6322c3 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 12:44:27 -0700 Subject: [PATCH 21/35] chore: try fix test --- integration/presets/utils.ts | 4 +++- .../templates/react-router-node/react-router.config.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/integration/presets/utils.ts b/integration/presets/utils.ts index 2d5d7a87414..f7831c39663 100644 --- a/integration/presets/utils.ts +++ b/integration/presets/utils.ts @@ -2,7 +2,9 @@ import path from 'node:path'; export function linkPackage(pkg: string) { // eslint-disable-next-line turbo/no-undeclared-env-vars - if (process.env.CI === 'true') return '*'; + if (process.env.CI === 'true') { + return '*'; + } return `link:${path.resolve(process.cwd(), `packages/${pkg}`)}`; } diff --git a/integration/templates/react-router-node/react-router.config.ts b/integration/templates/react-router-node/react-router.config.ts index b01bcc0cf09..77f1c2cbc06 100644 --- a/integration/templates/react-router-node/react-router.config.ts +++ b/integration/templates/react-router-node/react-router.config.ts @@ -6,5 +6,6 @@ export default { ssr: true, future: { v8_middleware: true, + unstable_optimizeDeps: true, }, } satisfies Config; From ea60b366af463a03b09388028685b3ae6dc53e6e Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 13:03:11 -0700 Subject: [PATCH 22/35] chore: try fix test --- integration/templates/react-router-node/package.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index dbe803b613f..f4edb4b86cd 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -12,15 +12,15 @@ "@react-router/node": "7.9.1", "@react-router/serve": "7.9.1", "isbot": "^5.1.17", - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-router": "7.9.1" }, "devDependencies": { "@react-router/dev": "7.9.1", "@types/node": "^20", - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "typescript": "^5.7.3", "vite": "^6.3.3", "vite-tsconfig-paths": "^5.1.4" diff --git a/package.json b/package.json index 9526e49d32e..0370f1d06d8 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs", "test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt", "test:integration:quickstart": "E2E_APP_ID=quickstart.* pnpm test:integration:base --grep @quickstart", - "test:integration:react-router": "E2E_APP_ID=react-router.* npm run test:integration:base -- --grep @react-router", + "test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router", "test:integration:sessions": "DISABLE_WEB_SECURITY=true pnpm test:integration:base --grep @sessions", "test:integration:tanstack-react-router": "E2E_APP_ID=tanstack.react-router pnpm test:integration:base --grep @tanstack-react-router", "test:integration:tanstack-react-start": "E2E_APP_ID=tanstack.react-start pnpm test:integration:base --grep @tanstack-react-start", From 6e92ca9f34d77778691dfac184100f8eddc2aed6 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 13:10:57 -0700 Subject: [PATCH 23/35] chore: try fix test --- integration/templates/react-router-node/app/root.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 614a03814fe..77c181d9348 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,9 +1,9 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +import { rootAuthLoader } from '@clerk/react-router/server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; -export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; +// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); From 041c9d4abfb552cb8d6bccfbd339536b3da4208d Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 13:19:49 -0700 Subject: [PATCH 24/35] chore: try fix test --- .changeset/quiet-bats-protect.md | 2 +- integration/templates/react-router-node/app/root.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md index 73f732b19fe..7326ce06540 100644 --- a/.changeset/quiet-bats-protect.md +++ b/.changeset/quiet-bats-protect.md @@ -1,5 +1,5 @@ --- -'@clerk/react-router': major +'@clerk/react-router': minor --- Introduce [React Router middleware](https://reactrouter.com/how-to/middleware) support with `clerkMiddleware()` for improved performance and streaming capabilities. diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 77c181d9348..614a03814fe 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,9 +1,9 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { rootAuthLoader } from '@clerk/react-router/server'; +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; -// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); From cab78e7ee167d0b9f338b7da6dcd39777b613cea Mon Sep 17 00:00:00 2001 From: Robert Soriano Date: Wed, 17 Sep 2025 13:38:23 -0700 Subject: [PATCH 25/35] Update quiet-bats-protect.md --- .changeset/quiet-bats-protect.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md index 7326ce06540..73f732b19fe 100644 --- a/.changeset/quiet-bats-protect.md +++ b/.changeset/quiet-bats-protect.md @@ -1,5 +1,5 @@ --- -'@clerk/react-router': minor +'@clerk/react-router': major --- Introduce [React Router middleware](https://reactrouter.com/how-to/middleware) support with `clerkMiddleware()` for improved performance and streaming capabilities. From ae32a9a6cae7461202f6aaaeff909a3557e385b4 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 15:39:05 -0700 Subject: [PATCH 26/35] chore: try fix test --- .../react-router-library/package.json | 14 +++++++------- .../templates/react-router-node/package.json | 18 +++++++++--------- .../tests/react-router/library-mode.test.ts | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/integration/templates/react-router-library/package.json b/integration/templates/react-router-library/package.json index 41fb0cc716e..5f802b3d17f 100644 --- a/integration/templates/react-router-library/package.json +++ b/integration/templates/react-router-library/package.json @@ -9,16 +9,16 @@ "preview": "vite preview --port $PORT" }, "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router": "7.9.1" + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.9.1" }, "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^5.0.3", "globals": "^15.12.0", "typescript": "~5.7.3", - "vite": "^6.0.1" + "vite": "^7.1.5" } } diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index f4edb4b86cd..3bcf6de6ba8 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -9,20 +9,20 @@ "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { - "@react-router/node": "7.9.1", - "@react-router/serve": "7.9.1", + "@react-router/node": "^7.9.1", + "@react-router/serve": "^7.9.1", "isbot": "^5.1.17", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router": "7.9.1" + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router": "^7.9.1" }, "devDependencies": { - "@react-router/dev": "7.9.1", + "@react-router/dev": "^7.9.1", "@types/node": "^20", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", "typescript": "^5.7.3", - "vite": "^6.3.3", + "vite": "^7.1.5", "vite-tsconfig-paths": "^5.1.4" } } diff --git a/integration/tests/react-router/library-mode.test.ts b/integration/tests/react-router/library-mode.test.ts index 6f5af6f63b5..8f47be527fe 100644 --- a/integration/tests/react-router/library-mode.test.ts +++ b/integration/tests/react-router/library-mode.test.ts @@ -5,7 +5,7 @@ import { appConfigs } from '../../presets'; import type { FakeOrganization, FakeUser } from '../../testUtils'; import { createTestUtils } from '../../testUtils'; -test.describe('Library Mode basic tests for @react-router', () => { +test.describe('Library Mode basic tests for @xreact-router', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; let fakeUser: FakeUser; From 37a6dbd839dd00b5a1e1412b4b31d15dd012e54a Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 15:52:55 -0700 Subject: [PATCH 27/35] chore: try fix test --- integration/templates/react-router-node/app/root.tsx | 4 ++-- .../templates/react-router-node/app/routes/protected.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 614a03814fe..c139f66c4a6 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,9 +1,9 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +import { rootAuthLoader } from '@clerk/react-router/ssr.server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; -export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; +// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); diff --git a/integration/templates/react-router-node/app/routes/protected.tsx b/integration/templates/react-router-node/app/routes/protected.tsx index 5b01b817505..9e2685f265f 100644 --- a/integration/templates/react-router-node/app/routes/protected.tsx +++ b/integration/templates/react-router-node/app/routes/protected.tsx @@ -1,6 +1,6 @@ import { redirect } from 'react-router'; import { UserProfile } from '@clerk/react-router'; -import { clerkClient, getAuth } from '@clerk/react-router/server'; +import { createClerkClient, getAuth } from '@clerk/react-router/ssr.server'; import type { Route } from './+types/protected'; export async function loader(args: Route.LoaderArgs) { @@ -10,7 +10,7 @@ export async function loader(args: Route.LoaderArgs) { return redirect('/sign-in'); } - const user = await clerkClient(args).users.getUser(userId); + const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId); return { firstName: user.firstName, From 09f675b38406f88315292136fa63f580f60a9de1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 16:23:40 -0700 Subject: [PATCH 28/35] chore: revert --- integration/templates/react-router-node/app/root.tsx | 4 ++-- .../templates/react-router-node/app/routes/protected.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index c139f66c4a6..614a03814fe 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,9 +1,9 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { rootAuthLoader } from '@clerk/react-router/ssr.server'; +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; -// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); diff --git a/integration/templates/react-router-node/app/routes/protected.tsx b/integration/templates/react-router-node/app/routes/protected.tsx index 9e2685f265f..5b01b817505 100644 --- a/integration/templates/react-router-node/app/routes/protected.tsx +++ b/integration/templates/react-router-node/app/routes/protected.tsx @@ -1,6 +1,6 @@ import { redirect } from 'react-router'; import { UserProfile } from '@clerk/react-router'; -import { createClerkClient, getAuth } from '@clerk/react-router/ssr.server'; +import { clerkClient, getAuth } from '@clerk/react-router/server'; import type { Route } from './+types/protected'; export async function loader(args: Route.LoaderArgs) { @@ -10,7 +10,7 @@ export async function loader(args: Route.LoaderArgs) { return redirect('/sign-in'); } - const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId); + const user = await clerkClient(args).users.getUser(userId); return { firstName: user.firstName, From 7d1c5bc88f0e2eddaf45b5cb06e3369fb33ac662 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 16:25:32 -0700 Subject: [PATCH 29/35] chore: revert --- integration/templates/react-router-node/app/root.tsx | 9 +++++---- .../react-router-node/app/routes/protected.tsx | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 614a03814fe..0bae3ebbc62 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,11 +1,12 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +import { rootAuthLoader } from '@clerk/react-router/ssr.server'; import { ClerkProvider } from '@clerk/react-router'; -import type { Route } from './+types/root'; -export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; +import type { Route } from './+types/root'; -export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); +export async function loader(args: Route.LoaderArgs) { + return rootAuthLoader(args); +} export function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/integration/templates/react-router-node/app/routes/protected.tsx b/integration/templates/react-router-node/app/routes/protected.tsx index 5b01b817505..8bb226a9e2b 100644 --- a/integration/templates/react-router-node/app/routes/protected.tsx +++ b/integration/templates/react-router-node/app/routes/protected.tsx @@ -1,6 +1,7 @@ import { redirect } from 'react-router'; import { UserProfile } from '@clerk/react-router'; -import { clerkClient, getAuth } from '@clerk/react-router/server'; +import { getAuth } from '@clerk/react-router/ssr.server'; +import { createClerkClient } from '@clerk/react-router/api.server'; import type { Route } from './+types/protected'; export async function loader(args: Route.LoaderArgs) { @@ -10,11 +11,10 @@ export async function loader(args: Route.LoaderArgs) { return redirect('/sign-in'); } - const user = await clerkClient(args).users.getUser(userId); + const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId); return { - firstName: user.firstName, - emailAddress: user.emailAddresses[0].emailAddress, + user, }; } @@ -24,8 +24,8 @@ export default function Profile({ loaderData }: Route.ComponentProps) {

Protected

    -
  • First name: {loaderData.firstName}
  • -
  • Email: {loaderData.emailAddress}
  • +
  • First name: {loaderData.user.firstName}
  • +
  • Email: {loaderData.user.emailAddresses[0].emailAddress}
); From ca25518a5b87ced5fa407a4d6b04bf814770cedf Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 16:33:56 -0700 Subject: [PATCH 30/35] chore: temporarily comment middleware from test --- integration/templates/react-router-node/app/root.tsx | 8 ++++---- integration/tests/react-router/library-mode.test.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 0bae3ebbc62..77e22a4e9ee 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,12 +1,12 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; import { rootAuthLoader } from '@clerk/react-router/ssr.server'; import { ClerkProvider } from '@clerk/react-router'; - import type { Route } from './+types/root'; -export async function loader(args: Route.LoaderArgs) { - return rootAuthLoader(args); -} +// TODO: Uncomment when published +// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; + +export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); export function Layout({ children }: { children: React.ReactNode }) { return ( diff --git a/integration/tests/react-router/library-mode.test.ts b/integration/tests/react-router/library-mode.test.ts index 8f47be527fe..6f5af6f63b5 100644 --- a/integration/tests/react-router/library-mode.test.ts +++ b/integration/tests/react-router/library-mode.test.ts @@ -5,7 +5,7 @@ import { appConfigs } from '../../presets'; import type { FakeOrganization, FakeUser } from '../../testUtils'; import { createTestUtils } from '../../testUtils'; -test.describe('Library Mode basic tests for @xreact-router', () => { +test.describe('Library Mode basic tests for @react-router', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; let fakeUser: FakeUser; From 74f4f364a2e076efe0630deb426a372221ee8702 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 16:35:35 -0700 Subject: [PATCH 31/35] pin snapshot --- integration/templates/react-router-node/app/root.tsx | 5 ++--- .../react-router-node/app/routes/protected.tsx | 12 ++++++------ integration/templates/react-router-node/package.json | 1 + 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 77e22a4e9ee..614a03814fe 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,10 +1,9 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { rootAuthLoader } from '@clerk/react-router/ssr.server'; +import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; -// TODO: Uncomment when published -// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; +export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); diff --git a/integration/templates/react-router-node/app/routes/protected.tsx b/integration/templates/react-router-node/app/routes/protected.tsx index 8bb226a9e2b..5b01b817505 100644 --- a/integration/templates/react-router-node/app/routes/protected.tsx +++ b/integration/templates/react-router-node/app/routes/protected.tsx @@ -1,7 +1,6 @@ import { redirect } from 'react-router'; import { UserProfile } from '@clerk/react-router'; -import { getAuth } from '@clerk/react-router/ssr.server'; -import { createClerkClient } from '@clerk/react-router/api.server'; +import { clerkClient, getAuth } from '@clerk/react-router/server'; import type { Route } from './+types/protected'; export async function loader(args: Route.LoaderArgs) { @@ -11,10 +10,11 @@ export async function loader(args: Route.LoaderArgs) { return redirect('/sign-in'); } - const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId); + const user = await clerkClient(args).users.getUser(userId); return { - user, + firstName: user.firstName, + emailAddress: user.emailAddresses[0].emailAddress, }; } @@ -24,8 +24,8 @@ export default function Profile({ loaderData }: Route.ComponentProps) {

Protected

    -
  • First name: {loaderData.user.firstName}
  • -
  • Email: {loaderData.user.emailAddresses[0].emailAddress}
  • +
  • First name: {loaderData.firstName}
  • +
  • Email: {loaderData.emailAddress}
); diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index 3bcf6de6ba8..c8ea64e7ab5 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -9,6 +9,7 @@ "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { + "@clerk/react-router": "2.0.0-snapshot.v20250917224107", "@react-router/node": "^7.9.1", "@react-router/serve": "^7.9.1", "isbot": "^5.1.17", From d718198d3c0b50e5710f4d5d5ef45f77f2df5573 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 16:54:44 -0700 Subject: [PATCH 32/35] chore: remove pinned snapshot --- integration/templates/react-router-node/app/root.tsx | 5 +++-- .../templates/react-router-node/app/routes/protected.tsx | 7 ++++--- integration/templates/react-router-node/package.json | 1 - 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 614a03814fe..77e22a4e9ee 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -1,9 +1,10 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'; -import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'; +import { rootAuthLoader } from '@clerk/react-router/ssr.server'; import { ClerkProvider } from '@clerk/react-router'; import type { Route } from './+types/root'; -export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; +// TODO: Uncomment when published +// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); diff --git a/integration/templates/react-router-node/app/routes/protected.tsx b/integration/templates/react-router-node/app/routes/protected.tsx index 5b01b817505..362fcac4fa4 100644 --- a/integration/templates/react-router-node/app/routes/protected.tsx +++ b/integration/templates/react-router-node/app/routes/protected.tsx @@ -1,7 +1,8 @@ import { redirect } from 'react-router'; import { UserProfile } from '@clerk/react-router'; -import { clerkClient, getAuth } from '@clerk/react-router/server'; -import type { Route } from './+types/protected'; +import { getAuth } from '@clerk/react-router/ssr.server'; +import { createClerkClient } from '@clerk/react-router/api.server'; +import type { Route } from './+types/profile'; export async function loader(args: Route.LoaderArgs) { const { userId } = await getAuth(args); @@ -10,7 +11,7 @@ export async function loader(args: Route.LoaderArgs) { return redirect('/sign-in'); } - const user = await clerkClient(args).users.getUser(userId); + const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId); return { firstName: user.firstName, diff --git a/integration/templates/react-router-node/package.json b/integration/templates/react-router-node/package.json index c8ea64e7ab5..3bcf6de6ba8 100644 --- a/integration/templates/react-router-node/package.json +++ b/integration/templates/react-router-node/package.json @@ -9,7 +9,6 @@ "typecheck": "react-router typegen && tsc --build --noEmit" }, "dependencies": { - "@clerk/react-router": "2.0.0-snapshot.v20250917224107", "@react-router/node": "^7.9.1", "@react-router/serve": "^7.9.1", "isbot": "^5.1.17", From 0975e9dd5f8cff682ae8e16f7c3b15fd274bdeb9 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 17:05:28 -0700 Subject: [PATCH 33/35] chore: fix tests --- integration/templates/react-router-node/app/root.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx index 77e22a4e9ee..e24f3b1a918 100644 --- a/integration/templates/react-router-node/app/root.tsx +++ b/integration/templates/react-router-node/app/root.tsx @@ -6,7 +6,9 @@ import type { Route } from './+types/root'; // TODO: Uncomment when published // export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]; -export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args); +export async function loader(args: Route.LoaderArgs) { + return rootAuthLoader(args); +} export function Layout({ children }: { children: React.ReactNode }) { return ( From d57eb20841dabaa9689f40e11b5d5dee74b28345 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 17:23:58 -0700 Subject: [PATCH 34/35] ignore library mode --- integration/tests/react-router/library-mode.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/tests/react-router/library-mode.test.ts b/integration/tests/react-router/library-mode.test.ts index 6f5af6f63b5..8f47be527fe 100644 --- a/integration/tests/react-router/library-mode.test.ts +++ b/integration/tests/react-router/library-mode.test.ts @@ -5,7 +5,7 @@ import { appConfigs } from '../../presets'; import type { FakeOrganization, FakeUser } from '../../testUtils'; import { createTestUtils } from '../../testUtils'; -test.describe('Library Mode basic tests for @react-router', () => { +test.describe('Library Mode basic tests for @xreact-router', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; let fakeUser: FakeUser; From e5a5194f2d6912f6722234131a7f08e5199fac1c Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Wed, 17 Sep 2025 17:47:33 -0700 Subject: [PATCH 35/35] Do not ignore library mode --- .../templates/react-router-library/package.json | 10 +++++----- integration/tests/react-router/library-mode.test.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/integration/templates/react-router-library/package.json b/integration/templates/react-router-library/package.json index 5f802b3d17f..4febd9a0dee 100644 --- a/integration/templates/react-router-library/package.json +++ b/integration/templates/react-router-library/package.json @@ -9,16 +9,16 @@ "preview": "vite preview --port $PORT" }, "dependencies": { - "react": "^19.1.0", - "react-dom": "^19.1.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", "react-router": "^7.9.1" }, "devDependencies": { - "@types/react": "^19.1.2", - "@types/react-dom": "^19.1.2", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^5.0.3", "globals": "^15.12.0", "typescript": "~5.7.3", - "vite": "^7.1.5" + "vite": "^6.0.1" } } diff --git a/integration/tests/react-router/library-mode.test.ts b/integration/tests/react-router/library-mode.test.ts index 8f47be527fe..6f5af6f63b5 100644 --- a/integration/tests/react-router/library-mode.test.ts +++ b/integration/tests/react-router/library-mode.test.ts @@ -5,7 +5,7 @@ import { appConfigs } from '../../presets'; import type { FakeOrganization, FakeUser } from '../../testUtils'; import { createTestUtils } from '../../testUtils'; -test.describe('Library Mode basic tests for @xreact-router', () => { +test.describe('Library Mode basic tests for @react-router', () => { test.describe.configure({ mode: 'parallel' }); let app: Application; let fakeUser: FakeUser;