diff --git a/.changeset/quiet-bats-protect.md b/.changeset/quiet-bats-protect.md
new file mode 100644
index 00000000000..73f732b19fe
--- /dev/null
+++ b/.changeset/quiet-bats-protect.md
@@ -0,0 +1,54 @@
+---
+'@clerk/react-router': major
+---
+
+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.
+
+**Before (Deprecated - will be removed):**
+
+```tsx
+import { rootAuthLoader } from '@clerk/react-router/ssr.server'
+
+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'
+
+export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
+
+export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)
+```
+
+**Streaming Support (with middleware):**
+
+```tsx
+export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
+
+export const 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/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-library/package.json b/integration/templates/react-router-library/package.json
index 28391f861f1..4febd9a0dee 100644
--- a/integration/templates/react-router-library/package.json
+++ b/integration/templates/react-router-library/package.json
@@ -9,15 +9,14 @@
"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.9.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
- "@vitejs/plugin-react": "^4.3.4",
+ "@vitejs/plugin-react": "^5.0.3",
"globals": "^15.12.0",
"typescript": "~5.7.3",
"vite": "^6.0.1"
diff --git a/integration/templates/react-router-node/app/root.tsx b/integration/templates/react-router-node/app/root.tsx
index 0bae3ebbc62..e24f3b1a918 100644
--- a/integration/templates/react-router-node/app/root.tsx
+++ b/integration/templates/react-router-node/app/root.tsx
@@ -1,9 +1,11 @@
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';
+// TODO: Uncomment when published
+// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()];
+
export async function loader(args: Route.LoaderArgs) {
return 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 2fdc2718e1c..362fcac4fa4 100644
--- a/integration/templates/react-router-node/app/routes/protected.tsx
+++ b/integration/templates/react-router-node/app/routes/protected.tsx
@@ -14,7 +14,8 @@ export async function loader(args: Route.LoaderArgs) {
const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId);
return {
- user,
+ firstName: user.firstName,
+ emailAddress: user.emailAddresses[0].emailAddress,
};
}
@@ -24,8 +25,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 aabe6a20c32..3bcf6de6ba8 100644
--- a/integration/templates/react-router-node/package.json
+++ b/integration/templates/react-router-node/package.json
@@ -9,21 +9,20 @@
"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.9.1",
+ "@react-router/serve": "^7.9.1",
"isbot": "^5.1.17",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "react-router": "^7.1.2"
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-router": "^7.9.1"
},
"devDependencies": {
- "@react-router/dev": "^7.1.2",
+ "@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": "^7.1.5",
+ "vite-tsconfig-paths": "^5.1.4"
}
}
diff --git a/integration/templates/react-router-node/react-router.config.ts b/integration/templates/react-router-node/react-router.config.ts
index 4f9a6ed5228..77f1c2cbc06 100644
--- a/integration/templates/react-router-node/react-router.config.ts
+++ b/integration/templates/react-router-node/react-router.config.ts
@@ -4,4 +4,8 @@ export default {
// Config options...
// Server-side render by default, to enable SPA mode set this to `false`
ssr: true,
+ future: {
+ v8_middleware: true,
+ unstable_optimizeDeps: true,
+ },
} satisfies Config;
diff --git a/integration/tests/react-router/basic.test.ts b/integration/tests/react-router/basic.test.ts
index 595a724304b..e67921ef416 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' });
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/package.json b/package.json
index e824b83e4e4..ba85402ae45 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,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 E2E_SESSIONS_APP_1_ENV_KEY=sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=sessions-prod-2 E2E_SESSIONS_APP_1_HOST=multiple-apps-e2e.clerk.app pnpm test:integration:base --grep @sessions",
"test:integration:sessions:staging": "DISABLE_WEB_SECURITY=true E2E_SESSIONS_APP_1_ENV_KEY=clerkstage-sessions-prod-1 E2E_SESSIONS_APP_2_ENV_KEY=clerkstage-sessions-prod-2 E2E_SESSIONS_APP_1_HOST=clerkstage-sessions-prod-1-e2e.clerk.app 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",
diff --git a/packages/react-router/package.json b/packages/react-router/package.json
index bdfa38997d0..9d0922abfc4 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"
],
@@ -97,7 +104,7 @@
"peerDependencies": {
"react": "catalog:peer-react",
"react-dom": "catalog:peer-react",
- "react-router": "^7.1.2"
+ "react-router": "^7.9.0"
},
"engines": {
"node": ">=20.0.0"
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 84aae121b9f..53f3377ed94 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",
@@ -54,9 +61,13 @@ 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`] = `
[
+ "clerkClient",
+ "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..1d9551d96c7 100644
--- a/packages/react-router/src/__tests__/exports.test.ts
+++ b/packages/react-router/src/__tests__/exports.test.ts
@@ -1,5 +1,8 @@
+import { logger } from '@clerk/shared/logger';
+import { vi } from 'vitest';
+
import * as publicExports from '../index';
-import * as ssrExports from '../ssr/index';
+import * as serverExports from '../server/index';
describe('root public exports', () => {
it('should not change unexpectedly', () => {
@@ -7,8 +10,18 @@ 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', 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/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/__tests__/clerkMiddleware.test.ts b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts
new file mode 100644
index 00000000000..8be8e33c419
--- /dev/null
+++ b/packages/react-router/src/server/__tests__/clerkMiddleware.test.ts
@@ -0,0 +1,151 @@
+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 = {
+ get: vi.fn(),
+ 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');
+ });
+});
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..742fc6f0ae1
--- /dev/null
+++ b/packages/react-router/src/server/__tests__/getAuth.test.ts
@@ -0,0 +1,71 @@
+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().mockImplementation(() => ({
+ userId: 'user_xxx',
+ tokenType: TokenType.SessionToken,
+ })),
+ 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().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 = {
+ context: mockContext,
+ request: new Request('http://clerk.com'),
+ } as LoaderFunctionArgs;
+
+ 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 () => {
+ const mockContext = {
+ get: vi.fn().mockReturnValue(null),
+ };
+
+ const args = {
+ context: mockContext,
+ request: new Request('http://clerk.com'),
+ } as LoaderFunctionArgs;
+
+ 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
new file mode 100644
index 00000000000..e8bc86e116b
--- /dev/null
+++ b/packages/react-router/src/server/__tests__/rootAuthLoader.test.ts
@@ -0,0 +1,251 @@
+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().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',
+ }),
+ };
+});
+
+describe('rootAuthLoader', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.CLERK_SECRET_KEY = 'sk_test_...';
+ });
+
+ describe('with middleware context', () => {
+ const mockContext = {
+ 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(),
+ };
+
+ const args = {
+ context: mockContext,
+ request: new Request('http://clerk.com'),
+ } as LoaderFunctionArgs;
+
+ it('should not call legacyAuthenticateRequest when middleware context exists', async () => {
+ const warnOnceSpy = vi.spyOn(logger, 'warnOnce').mockImplementation(() => {});
+
+ await rootAuthLoader(args, () => ({ data: 'test' }));
+
+ expect(legacyAuthenticateRequest).not.toHaveBeenCalled();
+ expect(warnOnceSpy).not.toHaveBeenCalled();
+
+ warnOnceSpy.mockRestore();
+ });
+
+ it('should handle no callback', 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 response = await rootAuthLoader(args, () => mockResponse);
+
+ 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()', async () => {
+ const result = await rootAuthLoader(args, () => data({ message: 'Hello from data()' }));
+
+ const response = result as unknown as Response;
+
+ 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();
+ });
+
+ 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);
+
+ expect(result).toHaveProperty('clerkState');
+ expect(legacyAuthenticateRequest).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('without middleware context', () => {
+ const mockContext = {
+ // No get/set methods - simulates v8_middleware flag not enabled
+ };
+
+ const args = {
+ context: mockContext,
+ request: new Request('http://clerk.com'),
+ } as LoaderFunctionArgs;
+
+ 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(warnOnceSpy).toHaveBeenCalledWith(middlewareMigrationWarning);
+ });
+
+ 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();
+
+ 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 () => {
+ const mockResponse = new Response(JSON.stringify({ message: 'Hello' }));
+
+ const response = await rootAuthLoader(args, () => mockResponse);
+
+ 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();
+ });
+
+ 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(response).toBeInstanceOf(Response);
+ 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();
+ });
+
+ 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);
+
+ 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 {}
+ 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();
+ });
+
+ it('should handle callback returning null', async () => {
+ const result = await rootAuthLoader(args, () => null);
+
+ const response = result as unknown as Response;
+
+ expect(result).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();
+ });
+ });
+});
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..458fb7c7bf4
--- /dev/null
+++ b/packages/react-router/src/server/clerkMiddleware.ts
@@ -0,0 +1,77 @@
+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 { 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 = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null);
+export const requestStateContext = 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
+ * // react-router.config.ts
+ * export default {
+ * future: {
+ * v8_middleware: true,
+ * },
+ * }
+ *
+ * // root.tsx
+ * export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
+ */
+export const clerkMiddleware = (options?: ClerkMiddlewareOptions): 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..0c3acbb171a
--- /dev/null
+++ b/packages/react-router/src/server/getAuth.ts
@@ -0,0 +1,46 @@
+import {
+ type AuthenticateRequestOptions,
+ type GetAuthFn,
+ getAuthObjectForAcceptedToken,
+} from '@clerk/backend/internal';
+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';
+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 = IsOptIntoMiddleware(args.context) && 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..fb78cd1fed7
--- /dev/null
+++ b/packages/react-router/src/server/index.ts
@@ -0,0 +1,5 @@
+export * from '@clerk/backend';
+export { clerkMiddleware } from './clerkMiddleware';
+export { rootAuthLoader } from './rootAuthLoader';
+export { getAuth } from './getAuth';
+export { clerkClient } from './clerkClient';
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..6c64a7face8 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 { 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..b3e108ca85b
--- /dev/null
+++ b/packages/react-router/src/server/rootAuthLoader.ts
@@ -0,0 +1,172 @@
+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,
+ IsOptIntoMiddleware,
+ 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 = IsOptIntoMiddleware(args.context) && !!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 hasMiddlewareFlag = IsOptIntoMiddleware(args.context);
+ const requestState = hasMiddlewareFlag && 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 89%
rename from packages/react-router/src/ssr/utils.ts
rename to packages/react-router/src/server/utils.ts
index 376539c3bc9..6b5552da0d6 100644
--- a/packages/react-router/src/ssr/utils.ts
+++ b/packages/react-router/src/server/utils.ts
@@ -40,10 +40,19 @@ 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,
context: AppLoadContext,
+ includeClerkHeaders = false,
) => {
const clone = new Response(response.body, response);
const data = await clone.json();
@@ -52,9 +61,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..28e761a5b0f 100644
--- a/packages/react-router/src/ssr/index.ts
+++ b/packages/react-router/src/ssr/index.ts
@@ -1,5 +1,18 @@
-export * from './rootAuthLoader';
-export * from './getAuth';
+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/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..be88c9880ae 100644
--- a/packages/react-router/src/utils/errors.ts
+++ b/packages/react-router/src/utils/errors.ts
@@ -94,3 +94,38 @@ Example:
`);
+
+const middlewareMigrationExample = `To use the new middleware system, you need to:
+
+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'
+
+export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
+
+export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)
+
+export default function App({ loaderData }: Route.ComponentProps) {
+ return (
+
+
+
+ )
+}
+`;
+
+export const middlewareMigrationWarning = createErrorMessage(`
+'"clerkMiddleware()" not detected.
+
+${middlewareMigrationExample}
+`);