Skip to content

Commit 8eaf44b

Browse files
authored
Fixed rewrite param parsing for interception routes in Vercel deployments (#79204)
> [!NOTE] > - Introduced in [PR #67400](#67400) and released in [v15.0.0-canary.54](https://github.com/vercel/next.js/releases/tag/v15.0.0-canary.54) > - See failed test in deployment [GitHub Actions Run](https://github.com/vercel/next.js/actions/runs/15083957601/job/42405327124?pr=79204#step:34:92) ### Summary This PR resolves two issues related to interception routes and rewrite behavior in Vercel deployments: 1. Rewrite params not parsed correctly from the `NEXT_ROUTER_STATE_TREE_HEADER`, resulting in server crashes due to missing dynamic params. 2. Malformed search query from Vercel deployment, where unresolved dynamic params like `:foo_id` were passed instead of actual values like `1`. These issues caused runtime errors and incorrect component behavior during navigation in deployed environments. This PR ensures consistent param resolution across local and deployed environments. ### Issue 1: Missing Rewrite Params in Vercel The `NEXT_ROUTER_STATE_TREE_HEADER` header, introduced in PR [#67400](#67400), wasn't parsed correctly during Vercel deployment. This was fine locally with `next start`, which uses [`getResolveRoutes`](https://github.com/vercel/next.js/blob/42b1c400c977cbe43147c083714e7f6bd38cff80/packages/next/src/shared/lib/router/utils/resolve-rewrites.ts#L14), but broke in Vercel which used [`handleRewrites`](https://github.com/vercel/next.js/blob/42b1c400c977cbe43147c083714e7f6bd38cff80/packages/next/src/server/server-utils.ts#L208). ``` TypeError: Expected "<a_dynamic_route_slug>" to be a string at async Object.handler (___next_launcher.cjs:58:5) at async Server.r (../../opt/rust/nodejs.js:2:14674) at async Server.<anonymous> (../../opt/rust/nodejs.js:16:6262) ``` This occurred because the expected dynamic route params were undefined, causing `path-to-regexp` to throw when trying to compile the destination path. ### Solution We ported the interception route rewrite logic from PR #67400, allowing consistent resolution of route params from the `NEXT_ROUTER_STATE_TREE_HEADER`. ### Issue 2: Malformed Query from Vercel Even after correct interception routing, the query string received from Vercel contained unresolved params such as `:foo_id`, resulting in: ```ts { rsc: 'aaaaa', nxtPfoo_id: ':foo_id', nxtPbar_id: ':bar_id', nxtPbaz: '1', } ``` Instead of the expected: ```ts { rsc: 'aaaaa', nxtPfoo_id: '1', nxtPbar_id: '1', nxtPbaz: '1', } ``` This happened because the rewrite rule: ```json { "source": "/:baz_id", "destination": "/:foo_id/:bar_id/(...)baz/:baz_id", ... } ``` Did not include `:foo_id` or `:bar_id` in the `source`, so they were never correctly mapped by Vercel CLI's [`convertRewrites`](https://github.com/vercel/vercel/blob/53e3609f144d08ad92d42291bcbf483ae592ff1b/packages/routing-utils/src/superstatic.ts#L165). ### Solution Since we already parse rewrite params from the `NEXT_ROUTER_STATE_TREE_HEADER`, we now inject those resolved values into `parsedUrl.query`. If any query value still starts with `:`, it's treated as unexpected and replaced with the rewrite params accordingly.
1 parent 5333dda commit 8eaf44b

File tree

11 files changed

+130
-1
lines changed

11 files changed

+130
-1
lines changed

.changeset/shaggy-owls-visit.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'next': patch
3+
---
4+
5+
Fixed rewrite params of the interception routes not being parsed correctly in certain deployed environments

packages/next/src/server/server-utils.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ import { decodeQueryPathParameter } from './lib/decode-query-path-parameter'
2727
import type { DeepReadonly } from '../shared/lib/deep-readonly'
2828
import { parseReqUrl } from '../lib/url'
2929
import { formatUrl } from '../shared/lib/router/utils/format-url'
30+
import { parseAndValidateFlightRouterState } from './app-render/parse-and-validate-flight-router-state'
31+
import { isInterceptionRouteRewrite } from '../lib/generate-interception-routes-rewrites'
32+
import { NEXT_ROUTER_STATE_TREE_HEADER } from '../client/components/app-router-headers'
33+
import { getSelectedParams } from '../client/components/router-reducer/compute-changed-path'
3034

3135
export function normalizeCdnUrl(
3236
req: BaseNextRequest | IncomingMessage,
@@ -209,7 +213,7 @@ export function getServerUtils({
209213
req: BaseNextRequest | IncomingMessage,
210214
parsedUrl: UrlWithParsedQuery
211215
) {
212-
const rewriteParams = {}
216+
const rewriteParams: Record<string, string> = {}
213217
let fsPathname = parsedUrl.pathname
214218

215219
const matchesPage = () => {
@@ -250,6 +254,28 @@ export function getServerUtils({
250254
}
251255

252256
if (params) {
257+
try {
258+
// An interception rewrite might reference a dynamic param for a route the user
259+
// is currently on, which wouldn't be extractable from the matched route params.
260+
// This attempts to extract the dynamic params from the provided router state.
261+
if (isInterceptionRouteRewrite(rewrite as Rewrite)) {
262+
const stateHeader =
263+
req.headers[NEXT_ROUTER_STATE_TREE_HEADER.toLowerCase()]
264+
265+
if (stateHeader) {
266+
params = {
267+
...getSelectedParams(
268+
parseAndValidateFlightRouterState(stateHeader)
269+
),
270+
...params,
271+
}
272+
}
273+
}
274+
} catch (err) {
275+
// this is a no-op -- we couldn't extract dynamic params from the provided router state,
276+
// so we'll just use the params from the route matcher
277+
}
278+
253279
const { parsedDestination, destQuery } = prepareDestination({
254280
appendParamsToQuery: true,
255281
destination: rewrite.destination,
@@ -266,6 +292,20 @@ export function getServerUtils({
266292
Object.assign(parsedUrl.query, parsedDestination.query)
267293
delete (parsedDestination as any).query
268294

295+
// for each property in parsedUrl.query, if the value is parametrized (eg :foo), look up the value
296+
// in rewriteParams and replace the parametrized value with the actual value
297+
// this is used when the rewrite destination does not contain the original source param
298+
// and so the value is still parametrized and needs to be replaced with the actual rewrite param
299+
Object.entries(parsedUrl.query).forEach(([key, value]) => {
300+
if (value && typeof value === 'string' && value.startsWith(':')) {
301+
const paramName = value.slice(1)
302+
const actualValue = rewriteParams[paramName]
303+
if (actualValue) {
304+
parsedUrl.query[key] = actualValue
305+
}
306+
}
307+
})
308+
269309
Object.assign(parsedUrl, parsedDestination)
270310

271311
fsPathname = parsedUrl.pathname
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Home() {
2+
return <p>intercepted!</p>
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default function Default() {
2+
return null
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function Layout(props: {
2+
children: React.ReactNode
3+
modal: React.ReactNode
4+
}) {
5+
return (
6+
<>
7+
{props.children}
8+
{props.modal}
9+
</>
10+
)
11+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Link from 'next/link'
2+
3+
export default async function Page(props: {
4+
params: Promise<{ foo_id: string; bar_id: string }>
5+
}) {
6+
const params = await props.params
7+
return (
8+
<>
9+
<h1>
10+
foo id {params.foo_id}, bar id {params.bar_id}
11+
</h1>
12+
<Link href="/baz_id/1">Link to bug report 1</Link>
13+
</>
14+
)
15+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export default async function Home({
2+
params,
3+
}: {
4+
params: Promise<{ baz_id: string }>
5+
}) {
6+
const { baz_id } = await params
7+
return <p>baz_id/{baz_id}</p>
8+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export default function RootLayout({
2+
children,
3+
}: {
4+
children: React.ReactNode
5+
}) {
6+
return (
7+
<html lang="en">
8+
<body>{children}</body>
9+
</html>
10+
)
11+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import Link from 'next/link'
2+
3+
export default function Home() {
4+
return <Link href="/1/1">Start from /1/1</Link>
5+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* @type {import('next').NextConfig}
3+
*/
4+
const nextConfig = {}
5+
6+
module.exports = nextConfig

0 commit comments

Comments
 (0)