Repository: vercel/next.js
Pull request: Preserve RSC metadata in Proxy request with skipProxyUrlNormalize

## Summary
- preserve Flight/RSC headers on the Proxy request instance when URL normalization is skipped (`skipProxyUrlNormalize`)
- preserve the `_rsc` query on the request URL in that same mode so custom proxy logic can access it
- document this behavior in the Proxy API docs, including guidance for `NextResponse.rewrite()` vs custom `fetch` rewrites
- add an e2e regression in `skip-trailing-slash-redirect` to verify middleware receives `_rsc` and all RSC headers on the request instance

## Testing
- `NEXT_SKIP_ISOLATE=1 NEXT_TEST_MODE=dev pnpm testheadless test/e2e/skip-trailing-slash-redirect/index.test.ts`


Changed files:
filename: docs/01-app/03-api-reference/03-file-conventions/proxy.mdx
patch: @@ -220,7 +220,7 @@ Proxy defaults to using the Node.js runtime. The [`runtime`](/docs/app/api-refer
 
 ## Advanced Proxy flags
 
-In `v13.1` of Next.js two additional flags were introduced for proxy, `skipMiddlewareUrlNormalize` and `skipTrailingSlashRedirect` to handle advanced use cases.
+In `v13.1` of Next.js two additional flags were introduced for proxy, `skipProxyUrlNormalize` (formerly `skipMiddlewareUrlNormalize`) and `skipTrailingSlashRedirect` to handle advanced use cases.
 
 `skipTrailingSlashRedirect` disables Next.js redirects for adding or removing trailing slashes. This allows custom handling inside proxy to maintain the trailing slash for some paths but not others, which can make incremental migrations easier.
 
@@ -252,11 +252,11 @@ export default async function proxy(req) {
 }
 ```
 
-`skipMiddlewareUrlNormalize` allows for disabling the URL normalization in Next.js to make handling direct visits and client-transitions the same. In some advanced cases, this option provides full control by using the original URL.
+`skipProxyUrlNormalize` allows for disabling the URL normalization in Next.js to make handling direct visits and client-transitions the same. In some advanced cases, this option provides full control by using the original URL.
 
 ```js filename="next.config.js"
 module.exports = {
-  skipMiddlewareUrlNormalize: true,
+  skipProxyUrlNormalize: true,
 }
 ```
 
@@ -433,6 +433,22 @@ Learn more in [NextResponse headers in Proxy](/docs/app/api-reference/functions/
 
 > **Good to know**: Avoid setting large headers as it might cause [431 Request Header Fields Too Large](https://developer.mozilla.org/docs/Web/HTTP/Status/431) error depending on your backend web server configuration.
 
+#### RSC requests and rewrites
+
+During RSC requests, Next.js strips internal Flight headers from the `request` instance in Proxy. For example, headers like `rsc`, `next-router-state-tree`, and `next-router-prefetch` are not exposed through `request.headers`. This is to prevent accidentally handling an RSC request differently than the HTML request as both need to align.
+
+When you use `NextResponse.rewrite()`, Next.js automatically propagates the required RSC rewrite headers upstream.
+
+If you implement custom rewrite logic with `fetch()` instead of `NextResponse.rewrite()`, you can run into missing RSC headers unless you forward them manually.
+
+For custom `fetch` rewrite setups, you can also enable `skipProxyUrlNormalize` in `next.config.js` so your rewrite logic can receive the necessary URL shape and RSC headers from the provided request object:
+
+```js filename="next.config.js"
+module.exports = {
+  skipProxyUrlNormalize: true,
+}
+```
+
 ### CORS
 
 You can set CORS headers in Proxy to allow cross-origin requests, including [simple](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests) and [preflighted](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#preflighted_requests) requests.

filename: packages/next/src/server/web/adapter.ts
patch: @@ -159,7 +159,7 @@ export async function adapter(
   const flightHeaders = new Map()
 
   // Headers should only be stripped for middleware
-  if (!isEdgeRendering) {
+  if (!isEdgeRendering && !process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE) {
     for (const header of FLIGHT_HEADERS) {
       const value = requestHeaders.get(header)
       if (value !== null) {
@@ -178,7 +178,9 @@ export async function adapter(
   const request = new NextRequestHint({
     page: params.page,
     // Strip internal query parameters off the request.
-    input: stripInternalSearchParams(normalizeURL).toString(),
+    input: process.env.__NEXT_NO_MIDDLEWARE_URL_NORMALIZE
+      ? normalizeURL.toString()
+      : stripInternalSearchParams(normalizeURL).toString(),
     init: {
       body: params.request.body,
       headers: requestHeaders,

filename: test/e2e/skip-trailing-slash-redirect/app/middleware.js
patch: @@ -50,11 +50,27 @@ export default function handler(req) {
 
   if (
     req.nextUrl.pathname.startsWith('/_next/data') &&
-    req.nextUrl.pathname.endsWith('valid.json')
+    req.nextUrl.pathname.endsWith('/valid.json')
   ) {
     return NextResponse.rewrite('https://example.vercel.sh')
   }
 
+  if (req.nextUrl.pathname.endsWith('/rsc-valid')) {
+    return NextResponse.json({
+      pathname: req.nextUrl.pathname,
+      rscQuery: req.nextUrl.searchParams.get('_rsc'),
+      rscHeaders: {
+        rsc: req.headers.get('rsc'),
+        nextRouterStateTree: req.headers.get('next-router-state-tree'),
+        nextRouterPrefetch: req.headers.get('next-router-prefetch'),
+        nextRouterSegmentPrefetch: req.headers.get(
+          'next-router-segment-prefetch'
+        ),
+        nextHmrRefresh: req.headers.get('next-hmr-refresh'),
+      },
+    })
+  }
+
   if (req.nextUrl.pathname.includes('/middleware-rewrite-with-slash')) {
     return NextResponse.rewrite(new URL('/another/', req.nextUrl))
   }

filename: test/e2e/skip-trailing-slash-redirect/index.test.ts
patch: @@ -1,6 +1,14 @@
 import { createNext, FileRef } from 'e2e-utils'
 import { NextInstance } from 'e2e-utils'
 import { check, fetchViaHTTP } from 'next-test-utils'
+import {
+  NEXT_HMR_REFRESH_HEADER,
+  NEXT_ROUTER_PREFETCH_HEADER,
+  NEXT_ROUTER_SEGMENT_PREFETCH_HEADER,
+  NEXT_ROUTER_STATE_TREE_HEADER,
+  NEXT_RSC_UNION_QUERY,
+  RSC_HEADER,
+} from 'next/dist/client/components/app-router-headers'
 import { join } from 'path'
 import cheerio from 'cheerio'
 import webdriver from 'next-webdriver'
@@ -243,6 +251,38 @@ describe('skip-trailing-slash-redirect', () => {
     expect(await res.text()).toContain('Example Domain')
   })
 
+  it('should receive _rsc query and all RSC headers in middleware request', async () => {
+    const rscQuery = 'cache-buster'
+    const rscHeaders = {
+      [RSC_HEADER]: '1',
+      [NEXT_ROUTER_STATE_TREE_HEADER]: 'test-tree',
+      [NEXT_ROUTER_PREFETCH_HEADER]: '1',
+      [NEXT_ROUTER_SEGMENT_PREFETCH_HEADER]: '/_tree',
+      [NEXT_HMR_REFRESH_HEADER]: '1',
+      'x-nextjs-data': '1',
+    }
+    const res = await fetchViaHTTP(
+      next.url,
+      `/rsc-valid?${NEXT_RSC_UNION_QUERY}=${rscQuery}`,
+      undefined,
+      { headers: rscHeaders }
+    )
+    expect(res.status).toBe(200)
+
+    expect(await res.json()).toEqual({
+      pathname: `/rsc-valid`,
+      rscQuery,
+      rscHeaders: {
+        rsc: rscHeaders[RSC_HEADER],
+        nextRouterStateTree: rscHeaders[NEXT_ROUTER_STATE_TREE_HEADER],
+        nextRouterPrefetch: rscHeaders[NEXT_ROUTER_PREFETCH_HEADER],
+        nextRouterSegmentPrefetch:
+          rscHeaders[NEXT_ROUTER_SEGMENT_PREFETCH_HEADER],
+        nextHmrRefresh: rscHeaders[NEXT_HMR_REFRESH_HEADER],
+      },
+    })
+  })
+
   it('should allow response body from middleware with flag', async () => {
     const res = await fetchViaHTTP(next.url, '/middleware-response-body')
     expect(res.status).toBe(200)