Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
55d3572
feat(react-router): Introduce middleware to improve auth perf
wobsoriano Aug 28, 2025
ca486bc
test(react-router): Unit test conditional response
wobsoriano Aug 28, 2025
464cb71
chore: apply suggested fixes
wobsoriano Aug 29, 2025
7e07dca
test(react-router): Clean up rootAuthLoader unit tests
wobsoriano Aug 29, 2025
b8f9998
test streaming
wobsoriano Aug 29, 2025
328d373
chore: update changeset
wobsoriano Aug 29, 2025
73577de
chore: update changeset
wobsoriano Aug 29, 2025
be2d0b0
chore: update changeset
wobsoriano Aug 29, 2025
6ed6edd
chore: improve unit tests
wobsoriano Aug 30, 2025
db4ca90
chore: add clerkMiddleware unit test
wobsoriano Aug 30, 2025
5df2647
chore: clean up error message
wobsoriano Aug 30, 2025
5fd6ef8
Merge branch 'main' into rob/user-3317-bu
wobsoriano Sep 2, 2025
07406f4
Merge branch 'main' into rob/user-3317-bu
wobsoriano Sep 12, 2025
7308966
chore: Remove unstable prefix
wobsoriano Sep 12, 2025
b356b0f
Merge branch 'main' into rob/user-3317-bu
wobsoriano Sep 12, 2025
d8aa016
chore: add v8_middleware flag
wobsoriano Sep 12, 2025
6d105a1
chore: make sure v8_middleware flag is enabled
wobsoriano Sep 15, 2025
e95ad51
chore: update changeset
wobsoriano Sep 15, 2025
ff004d0
chore: update middleware JSDoc
wobsoriano Sep 15, 2025
982f928
fix tests
wobsoriano Sep 15, 2025
e049ba8
fix tests
wobsoriano Sep 15, 2025
14450a3
pin versions
wobsoriano Sep 17, 2025
3d5871e
chore: run dedupe
wobsoriano Sep 17, 2025
dc715bd
Merge branch 'main' into rob/user-3317-bu
wobsoriano Sep 17, 2025
5f79582
chore: try fix test
wobsoriano Sep 17, 2025
ea60b36
chore: try fix test
wobsoriano Sep 17, 2025
6e92ca9
chore: try fix test
wobsoriano Sep 17, 2025
041c9d4
chore: try fix test
wobsoriano Sep 17, 2025
cab78e7
Update quiet-bats-protect.md
wobsoriano Sep 17, 2025
ae32a9a
chore: try fix test
wobsoriano Sep 17, 2025
37a6dbd
chore: try fix test
wobsoriano Sep 17, 2025
09f675b
chore: revert
wobsoriano Sep 17, 2025
7d1c5bc
chore: revert
wobsoriano Sep 17, 2025
ca25518
chore: temporarily comment middleware from test
wobsoriano Sep 17, 2025
74f4f36
pin snapshot
wobsoriano Sep 17, 2025
d718198
chore: remove pinned snapshot
wobsoriano Sep 17, 2025
0975e9d
chore: fix tests
wobsoriano Sep 18, 2025
d57eb20
ignore library mode
wobsoriano Sep 18, 2025
e5a5194
Do not ignore library mode
wobsoriano Sep 18, 2025
d94a79c
Merge branch 'main' into rob/user-3317-bu
wobsoriano Sep 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .changeset/quiet-bats-protect.md
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +35 to +37
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this always need to be in root.tsx or it can be in a specific route ?

Regarding naming, I think we should stick with clerkMiddleware and renamve authLoader to clerkLoader. WDYT ?

Copy link
Member Author

@wobsoriano wobsoriano Sep 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this always need to be in root.tsx or it can be in a specific route ?

Actually, rootAuthLoader can be used in any route that has a <ClerkProvider>. The key is:

  1. clerkMiddleware - Runs on every request to authenticate and provide auth context
  2. rootAuthLoader - Can be in any route loader where you want to provide initial auth state to <ClerkProvider>

But in our docs, we require to put the <ClerkProvider> in the root.tsx file to wrap the entire app

Regarding naming, I think we should stick with clerkMiddleware and rename authLoader to clerkLoader. WDYT ?

Great point! Since it's not actually root-specific, clerkLoader would be much clearer. We can deprecate the rootAuthLoader to not have a breaking change.

But we really also want to recommend putting it in the root.tsx file, thus the rootAuthLoader name. I suggest we keep this for now, as our react router docs has a lot of rootAuthLoader usage, and we can introduce the rename in a minor patch

```
Comment on lines +30 to +38
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

🧩 Analysis chain

Verify stable API names: middleware key, Route.MiddlewareFunction, and future.v8_middleware.

These names depend on the exact React Router version. Your PR text says “stable APIs” on >=7.9.0; if consumers are below that, they may still need unstable_middleware. Keep the snippet as-is if we’re hard-requiring 7.9+, but let’s double-check the flag and type names.


🌐 Web query:

For React Router v7.9.0 and later:
- Is the feature flag name `future.v8_middleware` correct for enabling middleware on the Node adapter?
- Is the route export key `middleware` (not `unstable_middleware`) the correct stable name?
- Is the type `Route.MiddlewareFunction` the correct stable type name?

💡 Result:

Short answers:

  • No. The runtime feature flag to enable middleware is future.unstable_middleware (not future.v8_middleware). [1][2]
  • No. The stable route export in the docs remains unstable_middleware (the docs show export const unstable_middleware = …). [1]
  • No. The documented type is Route.unstable_MiddlewareFunction (not Route.MiddlewareFunction). [1][3]

Sources:

  1. React Router Middleware docs (shows future.unstable_middleware, export unstable_middleware, and Route.unstable_MiddlewareFunction examples). [turn0search0].
  2. React Router changelog (v7.9.1 notes about internal Future interface naming fixes). [turn0search1].
  3. Route Module docs / examples showing unstable_middleware usage. [turn0search2].

Use documented unstable middleware names (fix snippet + PR text)

File: .changeset/quiet-bats-protect.md (lines 30–38)

  • Replace future.v8_middleware → future.unstable_middleware (runtime flag).
  • Replace export const middleware → export const unstable_middleware (route export).
  • Use Route.unstable_MiddlewareFunction (type) instead of Route.MiddlewareFunction.
  • Either update the snippet to use these unstable names, or change the PR to require and cite the exact React Router version that actually exposes stable middleware names and verify those names.
🤖 Prompt for AI Agents
In .changeset/quiet-bats-protect.md around lines 30–38, the provided snippet
uses stable middleware names that aren’t available; update the snippet and PR
text to use the unstable names: change the runtime flag future.v8_middleware to
future.unstable_middleware, change the route export from export const middleware
to export const unstable_middleware, and use the
Route.unstable_MiddlewareFunction type instead of Route.MiddlewareFunction;
alternatively, if you want to keep the stable names, update the PR to explicitly
require and cite the exact React Router version that exposes those stable
middleware names and verify the names in the documentation.


**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
}))
}
```
4 changes: 3 additions & 1 deletion integration/presets/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)}`;
}
5 changes: 2 additions & 3 deletions integration/templates/react-router-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 3 additions & 1 deletion integration/templates/react-router-node/app/root.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand All @@ -24,8 +25,8 @@ export default function Profile({ loaderData }: Route.ComponentProps) {
<h1>Protected</h1>
<UserProfile />
<ul>
<li>First name: {loaderData.user.firstName}</li>
<li>Email: {loaderData.user.emailAddresses[0].emailAddress}</li>
<li>First name: {loaderData.firstName}</li>
<li>Email: {loaderData.emailAddress}</li>
</ul>
</div>
);
Expand Down
21 changes: 10 additions & 11 deletions integration/templates/react-router-node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,20 @@
"typecheck": "react-router typegen && tsc --build --noEmit"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

tsc --build likely fails without "composite": true; switch to plain typecheck.

The template tsconfig.json lacks "composite": true, so tsc --build typically errors in single‑project setups. Prefer tsc --noEmit.

Apply:

   "scripts": {
     "build": "react-router build",
     "dev": "react-router dev --port $PORT",
     "start": "react-router-serve ./build/server/index.js",
-    "typecheck": "react-router typegen && tsc --build --noEmit"
+    "typecheck": "react-router typegen && tsc --noEmit"
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"typecheck": "react-router typegen && tsc --build --noEmit"
"scripts": {
"build": "react-router build",
"dev": "react-router dev --port $PORT",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc --noEmit"
},
🤖 Prompt for AI Agents
In integration/templates/react-router-node/package.json around line 9, the
"typecheck" script uses "tsc --build --noEmit" which requires "composite": true
in tsconfig; replace it with a plain typecheck invocation such as "react-router
typegen && tsc --noEmit" (remove --build) so the command runs in single-project
setups without requiring composite projects.

},
"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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 1 addition & 1 deletion integration/tests/react-router/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });

Expand Down
169 changes: 169 additions & 0 deletions integration/tests/react-router/pre-middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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 (
<html lang='en'>
<head>
<meta charSet='utf-8' />
<meta
name='viewport'
content='width=device-width, initial-scale=1'
/>
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}

export default function App({ loaderData }: Route.ComponentProps) {
return (
<ClerkProvider loaderData={loaderData}>
<main>
<Outlet />
</main>
</ClerkProvider>
);
}

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 (
<main>
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre>
<code>{stack}</code>
</pre>
)}
</main>
);
}
`,
)
.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();
});
});
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 9 additions & 2 deletions packages/react-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -55,6 +59,9 @@
"dist/*.d.ts",
"dist/index.d.ts"
],
"server": [
"dist/server/index.d.ts"
],
"ssr.server": [
"dist/ssr/index.d.ts"
],
Expand Down Expand Up @@ -92,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.1"
},
"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"
Expand Down
Loading
Loading