Skip to content

Commit 734ccb1

Browse files
authored
feat(expo-router): allow running server middleware with +middleware.ts (expo#38330)
# Why <!-- Please describe the motivation for this PR, and link to relevant GitHub issues, forums posts, or feature requests. --> In server mode (i.e. `web.output: server`), middleware allows users to run logic before a response has been sent by the server. Useful for scenarios such as authentication and logging. # How ## `@expo/cli` Added logic for Metro to bundle the middleware similar to an API route, as well as updating the route manifest to include middleware. Also ensured that static exports log a warning mentioning that middleware won't be exported. ## `@expo/server` Added logic to retrieve the bundled middleware and execute it first i.e. before redirects/rewrites. ## `expo-router` Added a new object `MiddlewareNode` which attaches to the root route node. <!-- How did you build this feature or fix this bug and why? --> # Test Plan CI (unit + E2E) + manual testing. <!-- Please describe how you tested this change and how a reviewer could reproduce your test, especially if this PR does not include automated tests! If possible, please also provide terminal output and/or screenshots demonstrating your test/reproduction. --> # Checklist <!-- Please check the appropriate items below if they apply to your diff. --> - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md)
1 parent f65dd50 commit 734ccb1

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+2577
-491
lines changed
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { MiddlewareFunction } from 'expo-router/server';
2+
import { jwtVerify, SignJWT } from 'jose';
3+
4+
const secret = new TextEncoder().encode(process.env.TEST_SECRET_KEY);
5+
const secret2 = new TextEncoder().encode(process.env.TEST_SECRET_KEY + '111');
6+
7+
const middleware: MiddlewareFunction = async (request) => {
8+
const url = new URL(request.url);
9+
const scenario = url.searchParams.get('e2e');
10+
11+
if (scenario === 'redirect') {
12+
return Response.redirect(new URL('/second', url.origin));
13+
}
14+
15+
if (scenario === 'redirect-301') {
16+
return Response.redirect(new URL('/second', url.origin), 301);
17+
}
18+
19+
if (scenario === 'error') {
20+
throw new Error('The middleware threw an error');
21+
}
22+
23+
if (scenario === 'read-env') {
24+
return Response.json({
25+
...process.env,
26+
})
27+
}
28+
29+
if (scenario === 'custom-response') {
30+
return new Response(`<html><h1 data-testid="title">Custom response from middleware</h1></html>`, {
31+
headers: {
32+
'content-type': 'text/html',
33+
}
34+
});
35+
}
36+
37+
if (scenario === 'sign-jwt') {
38+
const jwt = await new SignJWT({ foo: 'bar' })
39+
.setProtectedHeader({ alg: "HS256" })
40+
.setIssuedAt()
41+
.setExpirationTime("1h")
42+
.sign(secret);
43+
return new Response(JSON.stringify({ token: jwt }), {
44+
headers: {
45+
'content-type': 'application/json',
46+
}
47+
});
48+
}
49+
50+
if (scenario === 'verify-jwt') {
51+
const token = request.headers.get('authorization')!;
52+
const decoded = await jwtVerify(token, secret);
53+
return new Response(JSON.stringify({ payload: decoded.payload }), {
54+
headers: {
55+
'content-type': 'application/json',
56+
}
57+
});
58+
}
59+
60+
// If no E2E scenario is specified, continue to normal routing
61+
}
62+
63+
export default middleware;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/** @type {import('expo-router/server').RequestHandler} */
2+
export function GET() {
3+
return new Response(
4+
JSON.stringify({
5+
method: 'get',
6+
})
7+
);
8+
}
9+
10+
/** @type {import('expo-router/server').RequestHandler} */
11+
export function POST() {
12+
return new Response(
13+
JSON.stringify({
14+
method: 'post',
15+
})
16+
);
17+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Text, View } from "react-native";
2+
3+
export default function Index() {
4+
return (
5+
<View
6+
style={{
7+
flex: 1,
8+
justifyContent: "center",
9+
alignItems: "center",
10+
}}
11+
>
12+
<Text testID="title">Index</Text>
13+
</View>
14+
);
15+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { Text, View } from "react-native";
2+
3+
export default function Index() {
4+
return (
5+
<View
6+
style={{
7+
flex: 1,
8+
justifyContent: "center",
9+
alignItems: "center",
10+
}}
11+
>
12+
<Text testID="title">Second</Text>
13+
</View>
14+
);
15+
}

apps/router-e2e/app.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ module.exports = {
7474
rewrites: process.env.E2E_ROUTER_REWRITES
7575
? JSON.parse(process.env.E2E_ROUTER_REWRITES)
7676
: undefined,
77+
unstable_useServerMiddleware: process.env.E2E_ROUTER_SERVER_MIDDLEWARE === 'true',
7778
},
7879
],
7980
],

apps/router-e2e/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"expo-router": "^5.0.7",
4343
"expo-speech": "~13.1.7",
4444
"expo-sqlite": "~15.2.10",
45+
"jose": "^5",
4546
"react": "19.1.0",
4647
"react-native": "0.81.0-rc.5",
4748
"react-native-safe-area-context": "5.5.2",

docs/constants/navigation.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export const general = [
275275
makeGroup('Reference', [
276276
makePage('router/error-handling.mdx'),
277277
makePage('router/reference/url-parameters.mdx'),
278+
makePage('router/reference/middleware.mdx'),
278279
makePage('router/reference/redirects.mdx'),
279280
makePage('router/reference/static-rendering.mdx'),
280281
makePage('router/reference/async-routes.mdx'),

docs/pages/router/basics/notation.mdx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,21 @@ Layout routes are rendered before the actual page routes inside their directory.
4747

4848
### Plus sign
4949

50-
<FileTree files={[['app/+not-found.tsx'], ['app/+html.tsx'], ['app/+native-intent.tsx']]} />
50+
<FileTree
51+
files={[
52+
['app/+not-found.tsx'],
53+
['app/+html.tsx'],
54+
['app/+native-intent.tsx'],
55+
['app/+middleware.ts'],
56+
]}
57+
/>
58+
59+
Routes that include a `+` have special significance to Expo Router, and are used for specific purposes. A few examples:
5160

52-
Routes starting with a `+` have special significance to Expo Router, and are used for specific purposes. One example is [`+not-found`](/router/error-handling/#unmatched-routes), which catches any requests that don't match a route in your app. [`+html`](/router/reference/static-rendering/#root-html) is used to customize the HTML boilerplate used by your app on web. [`+native-intent`](/router/advanced/native-intent/) is used to handle deep links into your app that don't match a specific route, such as links generated by third party services.
61+
- [`+not-found`](/router/error-handling/#unmatched-routes), which catches any requests that don't match a route in your app.
62+
- [`+html`](/router/reference/static-rendering/#root-html) is used to customize the HTML boilerplate used by your app on web.
63+
- [`+native-intent`](/router/advanced/native-intent/) is used to handle deep links into your app that don't match a specific route, such as links generated by third-party services.
64+
- [`+middleware`](/router/reference/middleware/) is used to run code before a route is rendered, allowing you to perform tasks like authentication or redirection for every request.
5365

5466
## Route notation applied
5567

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
---
2+
title: Server middleware
3+
description: Learn how to create middleware that runs for every request to the server in Expo Router.
4+
---
5+
6+
import { Collapsible } from '~/ui/components/Collapsible';
7+
import { Terminal } from '~/ui/components/Snippet';
8+
import { Step } from '~/ui/components/Step';
9+
10+
> **important** Server middleware is an experimental feature, and requires a [deployed server](/router/reference/api-routes/#deployment) for production use.
11+
12+
Server middleware in Expo Router allows you to run code before requests reach your routes, enabling powerful server-side functionality like authentication and logging for every request. Unlike [API routes](/router/reference/api-routes) that handle specific endpoints, middleware runs for **every** request in your app, so it should run as quickly as possible to avoid slowing down your app's performance. Client-side navigation such as on native, or in a web app when using [`<Link />`](/versions/latest/sdk/router/#link), will not move through the server middleware.
13+
14+
## Setup
15+
16+
<Step label="1">
17+
18+
### Enable server middleware in your app configuration
19+
20+
First, configure your app to use server output by adding the server configuration to your [app config](/versions/latest/config/app/):
21+
22+
```json app.json
23+
{
24+
"expo": {
25+
/* @hide ... */
26+
/* @end */
27+
"web": {
28+
/* @info Middleware is only supported in server mode */
29+
"output": "server"
30+
/* @end */
31+
},
32+
"plugins": [
33+
[
34+
"expo-router",
35+
{
36+
/* @info Enables server middleware */
37+
"unstable_useServerMiddleware": true
38+
/* @end */
39+
}
40+
]
41+
]
42+
}
43+
}
44+
```
45+
46+
</Step>
47+
48+
<Step label="2">
49+
50+
### Create your middleware file
51+
52+
Create a **+middleware.ts** file in your **app** directory, to define your server middleware function:
53+
54+
```ts app/+middleware.ts
55+
export default function middleware(request) {
56+
console.log(`Middleware executed for: ${request.url}`);
57+
// Your middleware logic goes here
58+
}
59+
```
60+
61+
The middleware function must be the default export of the file. It receives an [immutable request](#request-immutability) and can return either a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response), or nothing to let the request pass through unmodified. The request is immutable to prevent side effects; you can read headers and properties, but you cannot modify headers or consume the request body.
62+
63+
</Step>
64+
65+
<Step label="3">
66+
67+
### Start your development server
68+
69+
Run your development server to test the middleware:
70+
71+
<Terminal cmd={['$ npx expo start']} />
72+
73+
Your middleware will now run for all requests to your app.
74+
75+
</Step>
76+
77+
<Step label="4">
78+
79+
### Test middleware functionality
80+
81+
Visit your app in a browser or make requests to test that your middleware is working. Check your console for the log messages from the middleware function.
82+
83+
</Step>
84+
85+
## How it works
86+
87+
Middleware functions are executed before any route handlers, allowing you to perform actions like logging, authentication, or modifying responses. It runs exclusively on the server and only for actual HTTP requests.
88+
89+
### Request/response flow
90+
91+
When a request comes to your app, Expo Router processes it in this order:
92+
93+
1. The middleware function runs first with an [immutable request](#request-immutability).
94+
2. If middleware returns a `Response`, that response is sent immediately
95+
3. If middleware returns nothing, the request continues to the matching route
96+
4. The route handler processes the request and returns its response
97+
98+
### Middleware execution order
99+
100+
Expo Router supports a single middleware file named **+middleware.ts** that runs for all server requests. Middleware executes before any route matching or rendering occurs, letting you control the request lifecycle.
101+
102+
### When middleware runs
103+
104+
Middleware executes only for actual HTTP requests to your server. This means it is executed for:
105+
106+
- Initial page loads, like when a user first visits your site
107+
- Full page refreshes
108+
- Direct URL navigation
109+
- API route calls from any client (native/web apps, external services)
110+
- Server-side rendering requests
111+
112+
Middleware does not run for:
113+
114+
- Client-side navigation using [`<Link />`](/versions/latest/sdk/router/#link) or [`router`](/versions/latest/sdk/router/#router)
115+
- Native app screen transitions
116+
- Prefetched routes
117+
- Static asset requests like images and fonts
118+
119+
## Examples
120+
121+
<Collapsible summary="Authentication">
122+
123+
Middleware is often used to perform authorization checks before a route has loaded. You can check headers, cookies, or query parameters to determine if a user has access to certain routes:
124+
125+
```ts app/+middleware.ts
126+
import { jwtVerify } from 'jose';
127+
128+
export default function middleware(request) {
129+
const token = request.headers.get('authorization');
130+
131+
const decoded = jwtVerify(token, process.env.SECRET_KEY);
132+
if (!decoded.payload) {
133+
return new Response('Forbidden', { status: 403 });
134+
}
135+
}
136+
```
137+
138+
</Collapsible>
139+
140+
<Collapsible summary="Logging">
141+
142+
You can use middleware to log requests for debugging or analytics purposes. This can help you track user activity or diagnose issues in your app:
143+
144+
```ts app/+middleware.ts
145+
export default function middleware(request) {
146+
console.log(`${request.method} ${request.url}`);
147+
}
148+
```
149+
150+
</Collapsible>
151+
152+
<Collapsible summary="Dynamic redirects">
153+
154+
Middleware can also be used to perform dynamic redirects. This allows you to control user navigation based on specific conditions:
155+
156+
```ts app/+middleware.ts
157+
export default function middleware(request) {
158+
if (request.headers.has('specific-header')) {
159+
return Response.redirect('https://expo.dev');
160+
}
161+
}
162+
```
163+
164+
</Collapsible>
165+
166+
## Additional notes
167+
168+
### Best practices
169+
170+
- Keep middleware lightweight because it runs synchronously on every server request and directly impacts response times.
171+
- For native apps, use API routes for secure data fetching. When native apps call API routes, those requests will pass through middleware first.
172+
173+
### Typed middleware
174+
175+
```ts app/+middleware.ts
176+
import { MiddlewareFunction } from 'expo-router';
177+
178+
const middleware: MiddlewareFunction = request => {
179+
if (request.headers.has('specific-header')) {
180+
return Response.redirect('https://expo.dev');
181+
}
182+
};
183+
184+
export default middleware;
185+
```
186+
187+
### Limitations
188+
189+
- Middleware runs exclusively on the server and only for HTTP requests. It does not execute during client-side navigation, for example, with [`<Link />`](/versions/latest/sdk/router/#link) or native app screen transitions.
190+
- The request object passed to middleware is [immutable](#request-immutability) to prevent side effects. You cannot modify headers or consume the request body, ensuring it remains available for route handlers.
191+
- You can only have one root-level **+middleware.ts** in your app.
192+
- The same limitations that [apply to API routes](/router/reference/api-routes/#known-limitations) also apply to middleware.
193+
194+
### Request immutability
195+
196+
To prevent unintended side effects and ensure the request body remains available for route handlers, the [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) passed to middleware is immutable. This means you can:
197+
198+
- Read all request properties like `url`, `method`, `headers`, and so on
199+
- Read header values using `request.headers.get()`
200+
- Check for header existence with `request.headers.has()`
201+
- Access URL parameters and query strings
202+
203+
But you won't be able to:
204+
205+
- Modify headers with `set()`, `append()`, `delete()`
206+
- Consume the request body with `text()`, `json()`, `formData()`, and so on
207+
- Access the `body` property directly

packages/@expo/cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
- Support external URLs with static redirects ([#38041](https://github.com/expo/expo/pull/38041) by [@hassankhan](https://github.com/hassankhan))
1717
- Add `EXPO_UNSTABLE_LIVE_BINDINGS` to allow developer to disable live binding in `experimentalImportSupport`. ([#38135](https://github.com/expo/expo/pull/38135) by [@krystofwoldrich](https://github.com/krystofwoldrich))
1818
- Add `location.origin`, Expo SDK version and Hermes version to sitemap UI ([#38201](https://github.com/expo/expo/pull/38201) by [@hassankhan](https://github.com/hassankhan))
19+
- Allow running server middleware with `+middleware.ts` ([#38330](https://github.com/expo/expo/pull/38330) by [@hassankhan](https://github.com/hassankhan))
1920

2021
### 🐛 Bug fixes
2122

0 commit comments

Comments
 (0)