From 07c1c5a171eac44cec0089ae1a4113ab25c66f31 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 15 Jun 2026 20:12:29 -0700 Subject: [PATCH 1/3] fix(pages): serialize full merged query into SSR __NEXT_DATA__.query (#1970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSR-rendered Pages Router pages serialized __NEXT_DATA__.query with only dynamic route params, dropping the URL querystring. This diverged from Next.js and from vinext's own client router, which writes the full merged query (querystring + route params) into __NEXT_DATA__.query after a soft navigation — so direct readers of __NEXT_DATA__.query saw a different shape on initial SSR vs. after a soft nav back to the same URL. Add a shared computePagesNextDataQuery helper in pages-readiness.ts that reproduces Next.js's serialization carve-out, and use it at all four SSR serialization sites (prod render + prod ISR regen in pages-page-response.ts / pages-page-data.ts, and dev SSR + dev ISR regen in dev-server.ts): - getServerSideProps / getInitialProps -> full merged query - getStaticProps (non-fallback) -> route params only (querystring dropped) - autoExport / getStaticPaths fallback -> {} useRouter().query is unaffected: the client recovers route params from the URL path and the querystring from location.search, independent of the serialized __NEXT_DATA__.query content — so the change only fixes direct readers, with no hydration impact. --- packages/vinext/src/server/dev-server.ts | 18 +++- packages/vinext/src/server/pages-page-data.ts | 3 + .../vinext/src/server/pages-page-response.ts | 11 ++- packages/vinext/src/server/pages-readiness.ts | 31 +++++++ tests/pages-page-data.test.ts | 1 + tests/pages-readiness.test.ts | 83 ++++++++++++++++++- tests/pages-router.test.ts | 53 ++++++++++++ 7 files changed, 195 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index eda610688..bfe67c1b4 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -61,7 +61,7 @@ import { resolvePagesI18nRequest, } from "./pages-i18n.js"; import { buildDefaultPagesNotFoundResponse } from "./pages-default-404.js"; -import { buildPagesReadinessNextData } from "./pages-readiness.js"; +import { buildPagesReadinessNextData, computePagesNextDataQuery } from "./pages-readiness.js"; import { resolvePagesPageMethodResponse } from "./pages-page-method.js"; import { getPagesRouteParams, @@ -1490,7 +1490,13 @@ export function createSSRHandler( { props: freshRenderProps, page: patternToNextFormat(route.pattern), - query: params, + query: computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: freshPagesNextData.autoExport, + gsp: freshPagesNextData.gsp, + }), buildId: process.env.__VINEXT_BUILD_ID, isFallback: false, locale: locale ?? currentDefaultLocale, @@ -1869,7 +1875,13 @@ export function createSSRHandler( { props: renderProps, page: patternToNextFormat(route.pattern), - query: params, + query: computePagesNextDataQuery({ + query, + params, + isFallback: isFallbackRender, + autoExport: serializedPagesNextData.autoExport, + gsp: serializedPagesNextData.gsp, + }), buildId: process.env.__VINEXT_BUILD_ID, isFallback: isFallbackRender, locale: locale ?? currentDefaultLocale, diff --git a/packages/vinext/src/server/pages-page-data.ts b/packages/vinext/src/server/pages-page-data.ts index ed11e5e3c..20fe1215b 100644 --- a/packages/vinext/src/server/pages-page-data.ts +++ b/packages/vinext/src/server/pages-page-data.ts @@ -136,6 +136,7 @@ type RenderPagesIsrHtmlOptions = { pageProps: Record; props?: Record; params: Record; + query: Record; renderIsrPassToStringAsync: (element: ReactNode) => Promise; routePattern: string; safeJsonStringify: (value: unknown) => string; @@ -653,6 +654,7 @@ export async function renderPagesIsrHtml(options: RenderPagesIsrHtmlOptions): Pr pageProps: options.pageProps, props: renderProps, params: options.params, + query: options.query, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, // Serialize the same readiness flags (gssp/gsp/autoExport/…) the initial @@ -947,6 +949,7 @@ export async function resolvePagesPageData( pageProps: freshPageProps, props: freshRenderProps, params: options.params, + query: options.query, renderIsrPassToStringAsync: options.renderIsrPassToStringAsync, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, diff --git a/packages/vinext/src/server/pages-page-response.ts b/packages/vinext/src/server/pages-page-response.ts index 115940836..e3d1171ef 100644 --- a/packages/vinext/src/server/pages-page-response.ts +++ b/packages/vinext/src/server/pages-page-response.ts @@ -23,6 +23,7 @@ import { type RenderPageEnhancers, runDocumentRenderPage, } from "./pages-document-initial-props.js"; +import { computePagesNextDataQuery } from "./pages-readiness.js"; import { fnv1a52 } from "../utils/hash.js"; import { readStreamAsText } from "../utils/text-stream.js"; import { callDocumentGetInitialProps } from "./document-initial-head.js"; @@ -274,6 +275,7 @@ export function buildPagesNextDataScript( | "pageProps" | "props" | "params" + | "query" | "routePattern" | "safeJsonStringify" | "scriptNonce" @@ -285,7 +287,13 @@ export function buildPagesNextDataScript( const nextDataPayload: Record = { props: options.props ?? { pageProps: options.pageProps }, page: options.routePattern, - query: options.params, + query: computePagesNextDataQuery({ + query: options.query ?? options.params, + params: options.params, + isFallback: options.isFallback === true, + autoExport: options.nextData?.autoExport, + gsp: options.nextData?.gsp, + }), buildId: options.buildId, isFallback: options.isFallback === true, }; @@ -503,6 +511,7 @@ export async function renderPagesPageResponse( pageProps: options.pageProps, props: renderProps, params: options.params, + query: options.query, routePattern: options.routePattern, safeJsonStringify: options.safeJsonStringify, scriptNonce: options.scriptNonce, diff --git a/packages/vinext/src/server/pages-readiness.ts b/packages/vinext/src/server/pages-readiness.ts index 01ac446c2..844569f99 100644 --- a/packages/vinext/src/server/pages-readiness.ts +++ b/packages/vinext/src/server/pages-readiness.ts @@ -53,3 +53,34 @@ export function buildPagesReadinessNextData(options: { __vinext: { hasRewrites: options.hasRewrites }, }; } + +/** + * Compute the `__NEXT_DATA__.query` value for a Pages Router SSR render, + * matching Next.js's serialization carve-out so the inlined value is identical + * to what the client router writes after a soft navigation (see + * `shims/router.ts`'s `mergeRouteParamsIntoQuery` call). Shared by the prod + * (`buildPagesNextDataScript`) and dev SSR render paths so they cannot drift. + * + * Next.js parity (render.tsx + next-server.ts `findPageComponents`): + * - getServerSideProps / page or _app `getInitialProps` → full merged query + * (URL querystring + route params). + * - getStaticProps (non-fallback render) → route params only; the querystring + * is dropped (a static page's output can't depend on the request query). + * - autoExport (no data-fetching exports) or a getStaticPaths fallback shell → + * `{}` (reset). Dynamic-route params are recovered client-side from the URL + * path on hydration, so resetting here is safe. + * + * `query` is the already-merged query (querystring + route params); `params` is + * the route-match params only. + */ +export function computePagesNextDataQuery(opts: { + query: Record; + params: Record; + isFallback: boolean; + autoExport: boolean | undefined; + gsp: boolean | undefined; +}): Record { + if (opts.isFallback || opts.autoExport) return {}; + if (opts.gsp) return opts.params; + return opts.query; +} diff --git a/tests/pages-page-data.test.ts b/tests/pages-page-data.test.ts index b2278a19e..633db9c3c 100644 --- a/tests/pages-page-data.test.ts +++ b/tests/pages-page-data.test.ts @@ -97,6 +97,7 @@ describe("pages page data", () => { }, pageProps: { title: "fresh" }, params: { slug: "post" }, + query: { slug: "post" }, renderIsrPassToStringAsync: vi.fn(async () => "
fresh-body
"), routePattern: "/posts/[slug]", safeJsonStringify(value: unknown) { diff --git a/tests/pages-readiness.test.ts b/tests/pages-readiness.test.ts index ab2b00f46..e320782b3 100644 --- a/tests/pages-readiness.test.ts +++ b/tests/pages-readiness.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildPagesReadinessNextData } from "../packages/vinext/src/server/pages-readiness.js"; +import { + buildPagesReadinessNextData, + computePagesNextDataQuery, +} from "../packages/vinext/src/server/pages-readiness.js"; import App from "../packages/vinext/src/shims/app.js"; import { getPagesNavigationIsReadyFromSerializedState } from "../packages/vinext/src/shims/router.js"; @@ -95,3 +98,81 @@ describe("buildPagesReadinessNextData", () => { }); }); }); + +describe("computePagesNextDataQuery", () => { + const query = { id: "42", q: "foo" }; + const params = { id: "42" }; + + it("returns the full merged query for getServerSideProps pages (#1970)", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: false, + gsp: undefined, + }), + ).toEqual({ id: "42", q: "foo" }); + }); + + it("returns the full merged query for getInitialProps pages", () => { + // gip/appGip pages classify as non-autoExport, non-gsp → full query. + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: false, + gsp: undefined, + }), + ).toEqual({ id: "42", q: "foo" }); + }); + + it("drops the querystring for getStaticProps pages (route params only)", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: false, + gsp: true, + }), + ).toEqual({ id: "42" }); + }); + + it("resets to {} for autoExport pages", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: false, + autoExport: true, + gsp: undefined, + }), + ).toEqual({}); + }); + + it("resets to {} for getStaticPaths fallback shell renders", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: true, + autoExport: false, + gsp: undefined, + }), + ).toEqual({}); + }); + + it("isFallback overrides gsp (a fallback shell of a gsp page resets to {})", () => { + expect( + computePagesNextDataQuery({ + query, + params, + isFallback: true, + autoExport: false, + gsp: true, + }), + ).toEqual({}); + }); +}); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index 7bdf6d8f5..fd56c99b8 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -8481,6 +8481,59 @@ describe("router __NEXT_DATA__ correctness (Pages Router)", () => { expect(nextData.props.pageProps.gsspCallId).toBeGreaterThan(0); }); + // Regression tests for GitHub issue #1970 — SSR-serialized __NEXT_DATA__.query + // must match Next.js (and vinext's own client router after a soft nav), which + // serialize the full merged query (querystring + route params) for gSSP/gIP + // pages, route params only for getStaticProps, and {} for autoExport/fallback. + it("gSSP page includes querystring + route params in __NEXT_DATA__.query (#1970)", async () => { + const res = await fetch(`${routerBaseUrl}/posts/hello-world?q=foo&utm=bar`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ id: "hello-world", q: "foo", utm: "bar" }); + }); + + it("gSSP route params win over same-name querystring in __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/posts/42?id=evil`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ id: "42" }); + }); + + it("gSSP non-dynamic page includes array querystring values in __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/shallow-test?a=1&a=2`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ a: ["1", "2"] }); + }); + + it("getStaticProps page drops the querystring from __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/blog/hello-world?x=1`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ slug: "hello-world" }); + }); + + it("autoExport dynamic page resets __NEXT_DATA__.query to {}", async () => { + const res = await fetch(`${routerBaseUrl}/nav-compat/foobar?a=pages`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.page).toBe("/nav-compat/[slug]"); + expect(nextData.query).toEqual({}); + }); + + it("getInitialProps page includes the full merged query in __NEXT_DATA__.query", async () => { + const res = await fetch(`${routerBaseUrl}/nav-compat-gip/foobar?q=pages`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.query).toEqual({ slug: "foobar", q: "pages" }); + }); + // Ported from Next.js: test/e2e/middleware-dynamic-basepath-matcher-rewrites // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-dynamic-basepath-matcher-rewrites // Regression test for GitHub issue #1196 — catch-all + basePath + rewrites + middleware. From 6ac156b428b871980a4d244ba625a0f84678eecc Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Mon, 15 Jun 2026 20:34:42 -0700 Subject: [PATCH 2/3] fix(pages): keep route params in __NEXT_DATA__.query for static/autoExport renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial #1970 change reset __NEXT_DATA__.query to {} for autoExport and fallback renders (Next.js parity). That broke client param recovery for rewritten dynamic routes: vinext's client reads a dynamic route's params from __NEXT_DATA__.query whenever the visible URL path doesn't match the route pattern (e.g. the rewrite /rewrite-navigation/0 -> /rewrite-navigation/[id]/ destination), because the params live nowhere else on the page. Emptying the query dropped useRouter().query.id on hydration — caught by the pages-router navigation e2e (initial load and query-only navigations). Narrow the carve-out so only per-request server renders (getServerSideProps / getInitialProps / _app.getInitialProps) serialize the full merged query — the actual #1970 fix. getStaticProps, autoExport, and fallback keep route params only (the original behavior), preserving rewrite param recovery while still dropping the querystring from static renders. Add an integration regression test asserting the rewritten autoExport route's __NEXT_DATA__.query retains its route param, and update the helper/integration tests to the corrected semantics. --- packages/vinext/src/server/pages-readiness.ts | 35 +++++++++++-------- tests/pages-readiness.test.ts | 22 ++++-------- tests/pages-router.test.ts | 19 ++++++++-- 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/packages/vinext/src/server/pages-readiness.ts b/packages/vinext/src/server/pages-readiness.ts index 844569f99..0606c5d24 100644 --- a/packages/vinext/src/server/pages-readiness.ts +++ b/packages/vinext/src/server/pages-readiness.ts @@ -55,20 +55,28 @@ export function buildPagesReadinessNextData(options: { } /** - * Compute the `__NEXT_DATA__.query` value for a Pages Router SSR render, - * matching Next.js's serialization carve-out so the inlined value is identical - * to what the client router writes after a soft navigation (see - * `shims/router.ts`'s `mergeRouteParamsIntoQuery` call). Shared by the prod - * (`buildPagesNextDataScript`) and dev SSR render paths so they cannot drift. + * Compute the `__NEXT_DATA__.query` value for a Pages Router SSR render so the + * inlined value is identical to what the client router writes after a soft + * navigation (see `shims/router.ts`'s `mergeRouteParamsIntoQuery` call). Shared + * by the prod (`buildPagesNextDataScript`) and dev SSR render paths so they + * cannot drift. * - * Next.js parity (render.tsx + next-server.ts `findPageComponents`): * - getServerSideProps / page or _app `getInitialProps` → full merged query - * (URL querystring + route params). - * - getStaticProps (non-fallback render) → route params only; the querystring - * is dropped (a static page's output can't depend on the request query). - * - autoExport (no data-fetching exports) or a getStaticPaths fallback shell → - * `{}` (reset). Dynamic-route params are recovered client-side from the URL - * path on hydration, so resetting here is safe. + * (URL querystring + route params). This is the #1970 fix: a per-request + * server render's `__NEXT_DATA__.query` must carry the querystring, matching + * Next.js and the client router after a soft nav. + * - getStaticProps → route params only; the querystring is dropped (a static + * page's output can't depend on the request query — Next.js parity). + * - autoExport / getStaticPaths fallback shell → route params only. + * + * Every non-gSSP/gIP case keeps the route params (rather than resetting to `{}` + * for autoExport/fallback as Next.js does) because vinext's client recovers a + * dynamic route's params from `__NEXT_DATA__.query` whenever the visible URL + * path doesn't match the route pattern — e.g. a rewrite from + * `/rewrite-navigation/0` to `/rewrite-navigation/[id]/destination`, where the + * params live nowhere else on the page (`getRouteQueryFromNextData` in + * `shims/router.ts`). Emptying the query there would drop `useRouter().query`'s + * route params on hydration. * * `query` is the already-merged query (querystring + route params); `params` is * the route-match params only. @@ -80,7 +88,6 @@ export function computePagesNextDataQuery(opts: { autoExport: boolean | undefined; gsp: boolean | undefined; }): Record { - if (opts.isFallback || opts.autoExport) return {}; - if (opts.gsp) return opts.params; + if (opts.isFallback || opts.autoExport || opts.gsp) return opts.params; return opts.query; } diff --git a/tests/pages-readiness.test.ts b/tests/pages-readiness.test.ts index e320782b3..3ae1ba39d 100644 --- a/tests/pages-readiness.test.ts +++ b/tests/pages-readiness.test.ts @@ -140,7 +140,9 @@ describe("computePagesNextDataQuery", () => { ).toEqual({ id: "42" }); }); - it("resets to {} for autoExport pages", () => { + it("keeps route params for autoExport pages (drops the querystring)", () => { + // Route params are kept (not reset to {}) so vinext's client can recover a + // rewritten dynamic route's params from __NEXT_DATA__.query on hydration. expect( computePagesNextDataQuery({ query, @@ -149,10 +151,10 @@ describe("computePagesNextDataQuery", () => { autoExport: true, gsp: undefined, }), - ).toEqual({}); + ).toEqual({ id: "42" }); }); - it("resets to {} for getStaticPaths fallback shell renders", () => { + it("keeps route params for getStaticPaths fallback shell renders", () => { expect( computePagesNextDataQuery({ query, @@ -161,18 +163,6 @@ describe("computePagesNextDataQuery", () => { autoExport: false, gsp: undefined, }), - ).toEqual({}); - }); - - it("isFallback overrides gsp (a fallback shell of a gsp page resets to {})", () => { - expect( - computePagesNextDataQuery({ - query, - params, - isFallback: true, - autoExport: false, - gsp: true, - }), - ).toEqual({}); + ).toEqual({ id: "42" }); }); }); diff --git a/tests/pages-router.test.ts b/tests/pages-router.test.ts index fd56c99b8..f3a045d96 100644 --- a/tests/pages-router.test.ts +++ b/tests/pages-router.test.ts @@ -8517,13 +8517,15 @@ describe("router __NEXT_DATA__ correctness (Pages Router)", () => { expect(nextData.query).toEqual({ slug: "hello-world" }); }); - it("autoExport dynamic page resets __NEXT_DATA__.query to {}", async () => { + it("autoExport dynamic page keeps route params (drops querystring) in __NEXT_DATA__.query", async () => { const res = await fetch(`${routerBaseUrl}/nav-compat/foobar?a=pages`); expect(res.status).toBe(200); const html = await res.text(); const nextData = readNextData(html); expect(nextData.page).toBe("/nav-compat/[slug]"); - expect(nextData.query).toEqual({}); + // Route params are retained (not reset to {}) so the client can recover them + // on hydration; the non-route querystring is dropped for a static render. + expect(nextData.query).toEqual({ slug: "foobar" }); }); it("getInitialProps page includes the full merged query in __NEXT_DATA__.query", async () => { @@ -8534,6 +8536,19 @@ describe("router __NEXT_DATA__ correctness (Pages Router)", () => { expect(nextData.query).toEqual({ slug: "foobar", q: "pages" }); }); + it("rewritten autoExport route keeps its route params in __NEXT_DATA__.query", async () => { + // Regression guard for the rewrite case the navigation e2e exercises: + // /rewrite-navigation/0 rewrites to /rewrite-navigation/[id]/destination, so + // the visible URL path doesn't contain the `id` param. The client recovers + // it from __NEXT_DATA__.query, so the serialized query must keep `id`. + const res = await fetch(`${routerBaseUrl}/rewrite-navigation/0`); + expect(res.status).toBe(200); + const html = await res.text(); + const nextData = readNextData(html); + expect(nextData.page).toBe("/rewrite-navigation/[id]/destination"); + expect(nextData.query).toEqual({ id: "0" }); + }); + // Ported from Next.js: test/e2e/middleware-dynamic-basepath-matcher-rewrites // https://github.com/vercel/next.js/blob/canary/test/e2e/middleware-dynamic-basepath-matcher-rewrites // Regression test for GitHub issue #1196 — catch-all + basePath + rewrites + middleware. From 11a6961ea3c328f634e4150c492bf2ee2ecb99f2 Mon Sep 17 00:00:00 2001 From: Divanshu Chauhan Date: Wed, 24 Jun 2026 09:45:28 -0500 Subject: [PATCH 3/3] chore: remove duplicate gIP unit test (identical to gSSP case) --- tests/pages-readiness.test.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/tests/pages-readiness.test.ts b/tests/pages-readiness.test.ts index 3ae1ba39d..551df86f8 100644 --- a/tests/pages-readiness.test.ts +++ b/tests/pages-readiness.test.ts @@ -115,19 +115,6 @@ describe("computePagesNextDataQuery", () => { ).toEqual({ id: "42", q: "foo" }); }); - it("returns the full merged query for getInitialProps pages", () => { - // gip/appGip pages classify as non-autoExport, non-gsp → full query. - expect( - computePagesNextDataQuery({ - query, - params, - isFallback: false, - autoExport: false, - gsp: undefined, - }), - ).toEqual({ id: "42", q: "foo" }); - }); - it("drops the querystring for getStaticProps pages (route params only)", () => { expect( computePagesNextDataQuery({