Files
vercel__next.js/test/e2e/middleware-rewrites/app/middleware.js
Tim Neutkens 668cd3a4a7 fix: preserve middleware rewrite query in Pages API routes (#94905)
### What?

- Preserve the resolved routing `query` and dynamic `params` when
`NextNodeServer.runApi` invokes a bundled Pages API route.
- Keep `req.url` restored to the original browser-visible request URL
for compatibility.
- Add an end-to-end regression test for a middleware rewrite from
`/foo/bar?key=value` to `/api/proxy/bar?added=1&extra=2` using an
optional catch-all Pages API route.

### Why?

Middleware routing correctly resolves the destination query and
catch-all parameters, but the Node Pages API path restores `req.url`
before invoking the bundled handler and previously forwarded only
`waitUntil`. The handler then reparsed the original URL and produced
only `{ key: "value" }`, dropping the rewrite-added `added` and `extra`
values and the destination `slug` parameter.

This restores the behavior from Next.js 14 that regressed during the
Pages API handler-interface migration. The issue affects both webpack
and Turbopack because the data is lost in shared server routing after
bundling.

The public documentation describes the pieces separately—rewrites
preserve the original URL, dynamic API parameters appear in `req.query`,
and resolved routing queries include middleware changes—so this is a bug
fix rather than a new API contract.

Fixes #94647

### How?

`runApi` now forwards the existing request metadata together with the
resolved `query` and `match.params` through the bundled handler context.
The Pages API entrypoint already installs supplied request metadata
before `routeModule.prepare()`, which then uses these routed values
while retaining the original `req.url`.

The regression test asserts that:

- `req.url` remains `/foo/bar?key=value`.
- `req.query` contains the original `key`, rewrite-added `added` and
`extra`, and catch-all `slug` values.

### Verification

- `pnpm --filter=next build`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-turbo
test/e2e/middleware-rewrites/test/index.test.ts -t "should preserve
rewrite query and dynamic params in Pages API routes"`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-webpack
test/e2e/middleware-rewrites/test/index.test.ts -t "should preserve
rewrite query and dynamic params in Pages API routes"`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-turbo
test/e2e/middleware-rewrites/test/index.test.ts -t "should preserve
rewrite query and dynamic params in Pages API routes"`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-webpack
test/e2e/middleware-rewrites/test/index.test.ts -t "should preserve
rewrite query and dynamic params in Pages API routes"`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-turbo
test/e2e/api-support/api-support.test.ts -t "query|dynamic"`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-webpack
test/e2e/api-support/api-support.test.ts -t "query|dynamic"`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-dev-turbo
test/e2e/custom-routes/custom-routes.test.ts -t "api rewrite"`
- `NEXT_TEST_PREFER_OFFLINE=1 pnpm test-start-webpack
test/e2e/custom-routes/custom-routes.test.ts -t "api rewrite"`
- Pre-commit lint-staged checks (Prettier and ESLint)
- Not run continuously: `pnpm --filter=next dev` (the local watcher hits
the environment open-file limit with `EMFILE`; the focused package build
passed)

<!-- NEXT_JS_LLM_PR -->

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes shared Pages API request routing in the Node server and
adapter i18n route generation; behavior is restored to pre-regression
semantics but affects all bundled Pages API invocations.
> 
> **Overview**
> Fixes a regression where **Pages API** handlers saw only the original
URL query after a middleware rewrite, because `runApi` restored
`req.url` but did not pass the router’s resolved `query` and `params`
into the bundled handler context.
> 
> `NextNodeServer.runApi` now supplies `requestMeta` (including merged
`query` and `match.params`) alongside `waitUntil`, so `req.query` keeps
rewrite-added parameters and catch-all segments while `req.url` stays
the browser-visible path.
> 
> Adapter build output no longer applies i18n locale prefixing to
dynamic **Pages API** routes (`shouldLocalize` excludes API pages);
regular pages still get `nextLocale` routing.
> 
> Regression coverage: middleware rewrite e2e (`/foo/bar` → catch-all
API) and a production adapter test with `i18n` enabled.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ad415cfe57. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
2026-06-26 20:21:47 +02:00

210 lines
5.4 KiB
JavaScript

import { NextResponse } from 'next/server'
const PUBLIC_FILE = /\.(.*)$/
/**
* @param {import('next/server').NextRequest} request
*/
export async function middleware(request) {
const url = request.nextUrl
if (url.pathname.includes('article')) {
return NextResponse.next()
}
// this is needed for tests to get the BUILD_ID
if (url.pathname.startsWith('/_next/static/__BUILD_ID')) {
return NextResponse.next()
}
if (url.pathname.includes('/to/some/404/path')) {
return NextResponse.next({
'x-matched-path': '/404',
})
}
if (
url.pathname.includes(
'/middleware-external-rewrite-body-headers-return-body'
)
) {
const tmpHeaders = new Headers(request.headers)
tmpHeaders.set('x-hello-from-middleware1', 'hello')
return NextResponse.rewrite(
'https://next-data-api-endpoint.vercel.app/api/echo-body',
{
request: {
headers: tmpHeaders,
},
}
)
}
if (
url.pathname.includes(
'/middleware-external-rewrite-body-headers-return-headers'
)
) {
const tmpHeaders = new Headers(request.headers)
tmpHeaders.set('x-hello-from-middleware1', 'hello')
return NextResponse.rewrite(
'https://next-data-api-endpoint.vercel.app/api/echo-headers',
{
request: {
headers: tmpHeaders,
},
}
)
}
if (url.pathname.includes('/middleware-external-rewrite-body')) {
return NextResponse.rewrite(
'https://next-data-api-endpoint.vercel.app/api/echo-body'
)
}
if (url.pathname === '/foo/bar') {
const rewriteUrl = request.nextUrl.clone()
rewriteUrl.pathname = '/api/proxy/bar/'
rewriteUrl.searchParams.set('added', '1')
rewriteUrl.searchParams.set('extra', '2')
return NextResponse.rewrite(rewriteUrl)
}
if (url.pathname.includes('/rewrite-to-static')) {
request.nextUrl.pathname = '/static-ssg/post-1'
return NextResponse.rewrite(request.nextUrl)
}
if (url.pathname.includes('/fallback-true-blog/rewritten')) {
request.nextUrl.pathname = '/about'
return NextResponse.rewrite(request.nextUrl)
}
if (url.pathname.startsWith('/about') && url.searchParams.has('override')) {
const isExternal = url.searchParams.get('override') === 'external'
return NextResponse.rewrite(
isExternal
? 'https://example.vercel.sh'
: new URL('/ab-test/a', request.url)
)
}
if (url.pathname === '/rewrite-to-beforefiles-rewrite') {
url.pathname = '/beforefiles-rewrite'
return NextResponse.rewrite(url)
}
if (url.pathname === '/rewrite-to-afterfiles-rewrite') {
url.pathname = '/afterfiles-rewrite'
return NextResponse.rewrite(url)
}
if (url.pathname.startsWith('/to-blog')) {
const slug = url.pathname.split('/').pop()
url.pathname = `/fallback-true-blog/${slug}`
return NextResponse.rewrite(url)
}
if (url.pathname === '/rewrite-to-ab-test') {
let bucket = request.cookies.get('bucket')
if (!bucket) {
bucket = Math.random() >= 0.5 ? 'a' : 'b'
url.pathname = `/ab-test/${bucket}`
const response = NextResponse.rewrite(url)
response.cookies.set('bucket', bucket, { maxAge: 10 })
return response
}
url.pathname = `/${bucket}`
return NextResponse.rewrite(url)
}
if (url.pathname === '/rewrite-me-to-about') {
url.pathname = '/about'
return NextResponse.rewrite(url, {
headers: { 'x-rewrite-target': String(url) },
})
}
if (url.pathname === '/rewrite-me-with-a-colon') {
url.pathname = '/with:colon'
return NextResponse.rewrite(url)
}
if (url.pathname === '/colon:here') {
url.pathname = '/no-colon-here'
return NextResponse.rewrite(url)
}
if (url.pathname === '/rewrite-me-to-vercel') {
return NextResponse.rewrite('https://example.vercel.sh')
}
if (url.pathname === '/clear-query-params') {
const allowedKeys = ['allowed']
for (const key of [...url.searchParams.keys()]) {
if (!allowedKeys.includes(key)) {
url.searchParams.delete(key)
}
}
return NextResponse.rewrite(url)
}
if (url.pathname === '/dynamic-no-cache/1') {
const rewriteUrl =
request.headers.get('purpose') === 'prefetch'
? '/dynamic-no-cache/1'
: '/dynamic-no-cache/2'
url.pathname = rewriteUrl
return NextResponse.rewrite(url, {
headers: { 'x-middleware-cache': 'no-cache' },
})
}
if (
url.pathname === '/rewrite-me-without-hard-navigation' ||
url.searchParams.get('path') === 'rewrite-me-without-hard-navigation'
) {
url.searchParams.set('middleware', 'foo')
url.pathname = request.cookies.has('about-bypass')
? '/about-bypass'
: '/about'
return NextResponse.rewrite(url, {
headers: { 'x-middleware-cache': 'no-cache' },
})
}
if (url.pathname.endsWith('/dynamic-replace')) {
url.pathname = '/dynamic-fallback/catch-all'
return NextResponse.rewrite(url)
}
if (url.pathname.startsWith('/country')) {
const locale = url.searchParams.get('my-locale')
if (locale) {
url.locale = locale
}
const country = url.searchParams.get('country') || 'us'
if (!PUBLIC_FILE.test(url.pathname) && !url.pathname.includes('/api/')) {
url.pathname = `/country/${country}`
return NextResponse.rewrite(url)
}
}
if (url.pathname.startsWith('/i18n')) {
url.searchParams.set('locale', url.locale)
return NextResponse.rewrite(url)
}
return NextResponse.rewrite(request.nextUrl)
}