Skip to content

Commit 8743290

Browse files
authored
feat(react-router): Introduce middleware and context (#6660)
1 parent 9cf89cd commit 8743290

30 files changed

+1182
-184
lines changed

.changeset/quiet-bats-protect.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@clerk/react-router': major
3+
---
4+
5+
Introduce [React Router middleware](https://reactrouter.com/how-to/middleware) support with `clerkMiddleware()` for improved performance and streaming capabilities.
6+
7+
Usage of `rootAuthLoader` without the `clerkMiddleware()` installed is now deprecated and will be removed in the next major version.
8+
9+
**Before (Deprecated - will be removed):**
10+
11+
```tsx
12+
import { rootAuthLoader } from '@clerk/react-router/ssr.server'
13+
14+
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)
15+
```
16+
17+
**After (Recommended):**
18+
19+
1. Enable the `v8_middleware` future flag:
20+
21+
```ts
22+
// react-router.config.ts
23+
export default {
24+
future: {
25+
v8_middleware: true,
26+
},
27+
} satisfies Config;
28+
```
29+
30+
2. Use the middleware in your app:
31+
32+
```tsx
33+
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'
34+
35+
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
36+
37+
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)
38+
```
39+
40+
**Streaming Support (with middleware):**
41+
42+
```tsx
43+
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
44+
45+
export const loader = (args: Route.LoaderArgs) => {
46+
const nonCriticalData = new Promise((res) =>
47+
setTimeout(() => res('non-critical'), 5000),
48+
)
49+
50+
return rootAuthLoader(args, () => ({
51+
nonCriticalData
52+
}))
53+
}
54+
```

integration/presets/utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import path from 'node:path';
22

33
export function linkPackage(pkg: string) {
44
// eslint-disable-next-line turbo/no-undeclared-env-vars
5-
if (process.env.CI === 'true') return '*';
5+
if (process.env.CI === 'true') {
6+
return '*';
7+
}
68

79
return `link:${path.resolve(process.cwd(), `packages/${pkg}`)}`;
810
}

integration/templates/react-router-library/package.json

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99
"preview": "vite preview --port $PORT"
1010
},
1111
"dependencies": {
12-
"@clerk/react-router": "^0.1.2",
1312
"react": "^18.3.1",
1413
"react-dom": "^18.3.1",
15-
"react-router": "^7.1.2"
14+
"react-router": "^7.9.1"
1615
},
1716
"devDependencies": {
1817
"@types/react": "^18.3.12",
1918
"@types/react-dom": "^18.3.1",
20-
"@vitejs/plugin-react": "^4.3.4",
19+
"@vitejs/plugin-react": "^5.0.3",
2120
"globals": "^15.12.0",
2221
"typescript": "~5.7.3",
2322
"vite": "^6.0.1"

integration/templates/react-router-node/app/root.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
22
import { rootAuthLoader } from '@clerk/react-router/ssr.server';
33
import { ClerkProvider } from '@clerk/react-router';
4-
54
import type { Route } from './+types/root';
65

6+
// TODO: Uncomment when published
7+
// export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()];
8+
79
export async function loader(args: Route.LoaderArgs) {
810
return rootAuthLoader(args);
911
}

integration/templates/react-router-node/app/routes/protected.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ export async function loader(args: Route.LoaderArgs) {
1414
const user = await createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }).users.getUser(userId);
1515

1616
return {
17-
user,
17+
firstName: user.firstName,
18+
emailAddress: user.emailAddresses[0].emailAddress,
1819
};
1920
}
2021

@@ -24,8 +25,8 @@ export default function Profile({ loaderData }: Route.ComponentProps) {
2425
<h1>Protected</h1>
2526
<UserProfile />
2627
<ul>
27-
<li>First name: {loaderData.user.firstName}</li>
28-
<li>Email: {loaderData.user.emailAddresses[0].emailAddress}</li>
28+
<li>First name: {loaderData.firstName}</li>
29+
<li>Email: {loaderData.emailAddress}</li>
2930
</ul>
3031
</div>
3132
);

integration/templates/react-router-node/package.json

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,20 @@
99
"typecheck": "react-router typegen && tsc --build --noEmit"
1010
},
1111
"dependencies": {
12-
"@clerk/react-router": "latest",
13-
"@react-router/node": "^7.1.2",
14-
"@react-router/serve": "^7.1.2",
12+
"@react-router/node": "^7.9.1",
13+
"@react-router/serve": "^7.9.1",
1514
"isbot": "^5.1.17",
16-
"react": "^18.3.1",
17-
"react-dom": "^18.3.1",
18-
"react-router": "^7.1.2"
15+
"react": "^19.1.0",
16+
"react-dom": "^19.1.0",
17+
"react-router": "^7.9.1"
1918
},
2019
"devDependencies": {
21-
"@react-router/dev": "^7.1.2",
20+
"@react-router/dev": "^7.9.1",
2221
"@types/node": "^20",
23-
"@types/react": "^18.3.12",
24-
"@types/react-dom": "^18.3.1",
22+
"@types/react": "^19.1.2",
23+
"@types/react-dom": "^19.1.2",
2524
"typescript": "^5.7.3",
26-
"vite": "^5.4.11",
27-
"vite-tsconfig-paths": "^5.1.2"
25+
"vite": "^7.1.5",
26+
"vite-tsconfig-paths": "^5.1.4"
2827
}
2928
}

integration/templates/react-router-node/react-router.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ export default {
44
// Config options...
55
// Server-side render by default, to enable SPA mode set this to `false`
66
ssr: true,
7+
future: {
8+
v8_middleware: true,
9+
unstable_optimizeDeps: true,
10+
},
711
} satisfies Config;

integration/tests/react-router/basic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { FakeUser } from '../../testUtils';
55
import { createTestUtils, testAgainstRunningApps } from '../../testUtils';
66

77
testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes], withPattern: ['react-router.node'] })(
8-
'basic tests for @react-router',
8+
'basic tests for @react-router with middleware',
99
({ app }) => {
1010
test.describe.configure({ mode: 'parallel' });
1111

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { expect, test } from '@playwright/test';
2+
3+
import type { Application } from '../../models/application';
4+
import { appConfigs } from '../../presets';
5+
import type { FakeUser } from '../../testUtils';
6+
import { createTestUtils } from '../../testUtils';
7+
8+
test.describe('basic tests for @react-router without middleware', () => {
9+
test.describe.configure({ mode: 'parallel' });
10+
let app: Application;
11+
let fakeUser: FakeUser;
12+
13+
test.beforeAll(async () => {
14+
test.setTimeout(90_000); // Wait for app to be ready
15+
app = await appConfigs.reactRouter.reactRouterNode
16+
.clone()
17+
.addFile(
18+
`app/root.tsx`,
19+
() => `import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
20+
import { rootAuthLoader } from '@clerk/react-router/ssr.server';
21+
import { ClerkProvider } from '@clerk/react-router';
22+
23+
import type { Route } from './+types/root';
24+
25+
export async function loader(args: Route.LoaderArgs) {
26+
return rootAuthLoader(args);
27+
}
28+
29+
export function Layout({ children }: { children: React.ReactNode }) {
30+
return (
31+
<html lang='en'>
32+
<head>
33+
<meta charSet='utf-8' />
34+
<meta
35+
name='viewport'
36+
content='width=device-width, initial-scale=1'
37+
/>
38+
<Meta />
39+
<Links />
40+
</head>
41+
<body>
42+
{children}
43+
<ScrollRestoration />
44+
<Scripts />
45+
</body>
46+
</html>
47+
);
48+
}
49+
50+
export default function App({ loaderData }: Route.ComponentProps) {
51+
return (
52+
<ClerkProvider loaderData={loaderData}>
53+
<main>
54+
<Outlet />
55+
</main>
56+
</ClerkProvider>
57+
);
58+
}
59+
60+
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
61+
let message = 'Oops!';
62+
let details = 'An unexpected error occurred.';
63+
let stack: string | undefined;
64+
65+
if (isRouteErrorResponse(error)) {
66+
message = error.status === 404 ? '404' : 'Error';
67+
details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
68+
} else if (import.meta.env.DEV && error && error instanceof Error) {
69+
details = error.message;
70+
stack = error.stack;
71+
}
72+
73+
return (
74+
<main>
75+
<h1>{message}</h1>
76+
<p>{details}</p>
77+
{stack && (
78+
<pre>
79+
<code>{stack}</code>
80+
</pre>
81+
)}
82+
</main>
83+
);
84+
}
85+
`,
86+
)
87+
.commit();
88+
89+
await app.setup();
90+
await app.withEnv(appConfigs.envs.withEmailCodes);
91+
await app.dev();
92+
93+
const u = createTestUtils({ app });
94+
fakeUser = u.services.users.createFakeUser({
95+
fictionalEmail: true,
96+
withPhoneNumber: true,
97+
withUsername: true,
98+
});
99+
await u.services.users.createBapiUser(fakeUser);
100+
});
101+
102+
test.afterAll(async () => {
103+
await fakeUser.deleteIfExists();
104+
await app.teardown();
105+
});
106+
107+
test.afterEach(async ({ page, context }) => {
108+
const u = createTestUtils({ app, page, context });
109+
await u.page.signOut();
110+
await u.page.context().clearCookies();
111+
});
112+
113+
test('can sign in and user button renders', async ({ page, context }) => {
114+
const u = createTestUtils({ app, page, context });
115+
await u.po.signIn.goTo();
116+
117+
await u.po.signIn.setIdentifier(fakeUser.email);
118+
await u.po.signIn.setPassword(fakeUser.password);
119+
await u.po.signIn.continue();
120+
await u.po.expect.toBeSignedIn();
121+
122+
await u.page.waitForAppUrl('/');
123+
124+
await u.po.userButton.waitForMounted();
125+
await u.po.userButton.toggleTrigger();
126+
await u.po.userButton.waitForPopover();
127+
128+
await u.po.userButton.toHaveVisibleMenuItems([/Manage account/i, /Sign out$/i]);
129+
});
130+
131+
test('redirects to sign-in when unauthenticated', async ({ page, context }) => {
132+
const u = createTestUtils({ app, page, context });
133+
134+
await u.page.goToRelative('/protected');
135+
await u.page.waitForURL(`${app.serverUrl}/sign-in`);
136+
await u.po.signIn.waitForMounted();
137+
});
138+
139+
test('renders control components contents', async ({ page, context }) => {
140+
const u = createTestUtils({ app, page, context });
141+
142+
await u.page.goToAppHome();
143+
await expect(u.page.getByText('SignedOut')).toBeVisible();
144+
145+
await u.page.goToRelative('/sign-in');
146+
await u.po.signIn.waitForMounted();
147+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
148+
await u.po.expect.toBeSignedIn();
149+
await expect(u.page.getByText('SignedIn')).toBeVisible();
150+
});
151+
152+
test('renders user profile with SSR data', async ({ page, context }) => {
153+
const u = createTestUtils({ app, page, context });
154+
155+
await u.page.goToRelative('/sign-in');
156+
await u.po.signIn.waitForMounted();
157+
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
158+
await u.po.expect.toBeSignedIn();
159+
160+
await u.po.userButton.waitForMounted();
161+
await u.page.goToRelative('/protected');
162+
await u.po.userProfile.waitForMounted();
163+
164+
// Fetched from an API endpoint (/api/me), which is server-rendered.
165+
// This also verifies that the server middleware is working.
166+
await expect(u.page.getByText(`First name: ${fakeUser.firstName}`)).toBeVisible();
167+
await expect(u.page.getByText(`Email: ${fakeUser.email}`)).toBeVisible();
168+
});
169+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"test:integration:nextjs": "E2E_APP_ID=next.appRouter.* pnpm test:integration:base --grep @nextjs",
4949
"test:integration:nuxt": "E2E_APP_ID=nuxt.node npm run test:integration:base -- --grep @nuxt",
5050
"test:integration:quickstart": "E2E_APP_ID=quickstart.* pnpm test:integration:base --grep @quickstart",
51-
"test:integration:react-router": "E2E_APP_ID=react-router.* npm run test:integration:base -- --grep @react-router",
51+
"test:integration:react-router": "E2E_APP_ID=react-router.* pnpm test:integration:base --grep @react-router",
5252
"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",
5353
"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",
5454
"test:integration:tanstack-react-router": "E2E_APP_ID=tanstack.react-router pnpm test:integration:base --grep @tanstack-react-router",

0 commit comments

Comments
 (0)