From 7f1358866c7c8d87c134125a7ee502e47ac00da6 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Wed, 13 May 2026 14:50:03 +0200 Subject: [PATCH] Fix catch-all `router.query` corruption with `basePath` + `rewrites` (#93294) In a Pages Router app that combines a `basePath`, `next.config.js` `rewrites`, middleware, and a catch-all dynamic route, a client-side `Link` navigation could land on the page with a corrupted `router.query`. Instead of holding the captured route segments, the catch-all param ended up holding the segments of the internal `_next/data//...` URL. Page code that builds further URLs from `router.query` then produced malformed paths and 404s in production. The corruption originated in `getMiddlewareData`, which was passing `nextConfig: undefined` to `getNextPathnameInfo` whenever `__NEXT_HAS_REWRITES` was true. With basePath set, that meant the basePath prefix was never stripped from the data-source URL, so the `/_next/data/` check never matched and the data prefix was left intact. The resulting pathname flowed back into `routeInfo.resolvedAs`, and on catch-all routes the route regex matched it and overwrote `router.query.` with the data-URL segments. The original ternary at this call site (added in #48753) was there to avoid stripping the locale prefix, since `resolveRewrites` needs `as` to keep its locale prefix to match locale-aware rewrite sources. Disabling all of `nextConfig` was too coarse and broke basePath stripping in the process. The fix passes a partial config with only `basePath` and `trailingSlash`, which keeps the locale prefix intact while still letting `getNextPathnameInfo` strip basePath before the data-prefix check. The new `test/e2e/middleware-dynamic-basepath-matcher-rewrites/` fixture is the catch-all + basePath + rewrites + middleware variant of `test/e2e/middleware-dynamic-basepath-matcher/` (#48753's regression test), and asserts that `router.query.path` is preserved across a client-side `Link` navigation. --- packages/next/src/shared/lib/router/router.ts | 11 ++++++++--- ...-dynamic-basepath-matcher-rewrites.test.ts | 19 +++++++++++++++++++ .../middleware.js | 9 +++++++++ .../next.config.js | 8 ++++++++ .../pages/[...path].tsx | 15 +++++++++++++++ .../pages/index.tsx | 9 +++++++++ 6 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware-dynamic-basepath-matcher-rewrites.test.ts create mode 100644 test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware.js create mode 100644 test/e2e/middleware-dynamic-basepath-matcher-rewrites/next.config.js create mode 100644 test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/[...path].tsx create mode 100644 test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/index.tsx diff --git a/packages/next/src/shared/lib/router/router.ts b/packages/next/src/shared/lib/router/router.ts index 088c34aa1d7a..a164d6c63d5f 100644 --- a/packages/next/src/shared/lib/router/router.ts +++ b/packages/next/src/shared/lib/router/router.ts @@ -227,9 +227,14 @@ function getMiddlewareData( const parsedSource = getNextPathnameInfo( parseRelativeUrl(source).pathname, { - nextConfig: process.env.__NEXT_HAS_REWRITES - ? undefined - : nextConfig, + // Pass basePath (and trailingSlash) so the basePath prefix is + // stripped before the `_next/data/` check, but omit `i18n` so the + // locale prefix is preserved here — the rewrite resolver below + // handles the locale-prefixed `as`. + nextConfig: { + basePath: nextConfig.basePath, + trailingSlash: nextConfig.trailingSlash, + }, parseData: true, } ) diff --git a/test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware-dynamic-basepath-matcher-rewrites.test.ts b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware-dynamic-basepath-matcher-rewrites.test.ts new file mode 100644 index 000000000000..93b0863623ea --- /dev/null +++ b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware-dynamic-basepath-matcher-rewrites.test.ts @@ -0,0 +1,19 @@ +import { nextTestSetup } from 'e2e-utils' +import { retry } from 'next-test-utils' + +describe('middleware-dynamic-basepath-matcher-rewrites', () => { + const { next } = nextTestSetup({ + files: __dirname, + }) + + it('preserves router.query on client-side navigation to a catch-all page', async () => { + const browser = await next.browser('/docs') + await browser.elementById('catchall-link').click() + + await retry(async () => { + expect(await browser.elementById('page-title').text()).toBe('CatchAll') + }) + + expect(await browser.elementById('query-path').text()).toBe('["first"]') + }) +}) diff --git a/test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware.js b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware.js new file mode 100644 index 000000000000..9702fc711463 --- /dev/null +++ b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/middleware.js @@ -0,0 +1,9 @@ +import { NextResponse } from 'next/server' + +export default function middleware() { + return NextResponse.next() +} + +export const config = { + matcher: '/:path*', +} diff --git a/test/e2e/middleware-dynamic-basepath-matcher-rewrites/next.config.js b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/next.config.js new file mode 100644 index 000000000000..74a5f22a11d9 --- /dev/null +++ b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/next.config.js @@ -0,0 +1,8 @@ +module.exports = { + basePath: '/docs', + async rewrites() { + // Any non-empty rewrites array sets __NEXT_HAS_REWRITES=true; this entry + // only exists to flip that build flag, it isn't expected to match. + return [{ source: '/never-matched-rewrite', destination: '/never' }] + }, +} diff --git a/test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/[...path].tsx b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/[...path].tsx new file mode 100644 index 000000000000..3d25ccc901ff --- /dev/null +++ b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/[...path].tsx @@ -0,0 +1,15 @@ +import { useRouter } from 'next/router' + +export default function CatchAll() { + const router = useRouter() + return ( +
+

CatchAll

+

{JSON.stringify(router.query.path)}

+
+ ) +} + +export async function getServerSideProps() { + return { props: {} } +} diff --git a/test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/index.tsx b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/index.tsx new file mode 100644 index 000000000000..de044e9c8068 --- /dev/null +++ b/test/e2e/middleware-dynamic-basepath-matcher-rewrites/pages/index.tsx @@ -0,0 +1,9 @@ +import Link from 'next/link' + +export default function Home() { + return ( + + Go to catchall + + ) +}