Skip to content

fix(pages): serialize full merged query into SSR __NEXT_DATA__.query (#1970)#2060

Open
Divkix wants to merge 3 commits into
cloudflare:mainfrom
Divkix:fix/issue-1970-next-data-query
Open

fix(pages): serialize full merged query into SSR __NEXT_DATA__.query (#1970)#2060
Divkix wants to merge 3 commits into
cloudflare:mainfrom
Divkix:fix/issue-1970-next-data-query

Conversation

@Divkix

@Divkix Divkix commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #1970.

Problem

For SSR-rendered Pages Router pages with a querystring (notably getServerSideProps pages like /search?q=foo), the inlined window.__NEXT_DATA__.query contained only dynamic route params and dropped the URL querystring. This:

  • Diverges from Next.js, which serializes the full merged query (querystring + route params) into __NEXT_DATA__.query for gSSP/gIP pages.
  • Is internally inconsistent — vinext's own client router repopulates __NEXT_DATA__.query with the full merged query after a soft navigation (shims/router.ts, with a comment noting direct readers would otherwise see only dynamic params). So anything reading __NEXT_DATA__.query directly (test harnesses, analytics, third-party scripts) saw one shape on initial SSR and a different shape after a soft nav back to the same URL.

useRouter().query was already correct (the client recovers route params from the path and the querystring from location.search), so this was purely a __NEXT_DATA__.query serialization bug.

Fix

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):

Page type Serialized __NEXT_DATA__.query
getServerSideProps / page getInitialProps / _app.getInitialProps full merged (querystring + route params)
getStaticProps (non-fallback render) route params only (querystring dropped)
autoExport / getStaticPaths fallback shell {}

The carve-out is required, not cosmetic: a naive "always use the full query" change would regress the existing assertion that getStaticProps pages drop the querystring (tests/features.test.ts).

Why there's no hydration impact

shims/router.ts getRouteQueryFromNextData recovers dynamic route params for useRouter().query by matching window.location.pathname against __NEXT_DATA__.page (the route pattern), not from the content of __NEXT_DATA__.query; getPathnameAndQuery then merges location.search. So adding the querystring (gSSP) or resetting to {} (autoExport dynamic) does not affect useRouter().query — the change only fixes direct readers of __NEXT_DATA__.query.

ISR note: HTML ISR caching is gated to the getStaticProps path (gSSP responses are never written to the HTML cache, and cached gsp queries are querystring-independent), so the existing path-only cache key needs no change.

Tests

  • New tests/pages-readiness.test.ts unit-tests the helper across all branches (gSSP→full query, gsp→params, autoExport→{}, isFallback→{}, isFallback overriding gsp).
  • Eight new integration tests in tests/pages-router.test.ts (router __NEXT_DATA__ correctness block) cover gSSP querystring inclusion, route-param/querystring collision, array querystring values, getStaticProps querystring drop, autoExport-dynamic reset, and gIP full query — all reusing existing pages-basic fixtures.
  • Existing regression guards stay green: tests/features.test.ts getStaticProps carve-out and the prior top-level nextData.query assertions.

Verification

  • pages-router.test.ts: 319/319 passing · features.test.ts: 327/327 passing · helper unit test: 5/5 passing.

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2060
npm i https://pkg.pr.new/create-vinext-app@2060
npm i https://pkg.pr.new/vinext@2060

commit: 11a6961

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: fix(pages): serialize full merged query into SSR __NEXT_DATA__.query

Verified the fix against the codebase and Next.js behavior. This looks correct and well-tested. I checked out the branch and ran the relevant tests; all 8 new #1970 integration tests pass, the 5 unit tests pass, the getStaticProps carve-out regression guard in features.test.ts stays green, and vp check is clean on all 5 changed source files.

What I verified

Core claim holds. getRouteQueryFromNextData in shims/router.ts:1201 only reads route params from __NEXT_DATA__.query when the visible URL path does not match __NEXT_DATA__.page (the rewrite case at line 1208). When the path matches, params come from the path and the querystring from location.search. So adding the querystring (gSSP) does not affect useRouter().query — the change is purely a direct-reader fix, as described.

The autoExport/fallback carve-out keeping params (not {}) is correct and necessary. The rewrite-navigation/[id]/destination.tsx fixture is autoExport, and /rewrite-navigation/0 rewrites to a path that doesn't contain id, so the client recovers id from __NEXT_DATA__.query. Resetting to {} (Next.js behavior) would drop it. The new regression test guards exactly this. Good catch documenting the intentional divergence.

SSR/soft-nav parity confirmed. All SSR sites pass mergeRouteParamsIntoQuery(parseQuery(url), params) as query (dev-server.ts:579, pages-page-handler.ts:421), matching the client soft-nav value at shims/router.ts:1815 — including route-params-win-on-collision (the /posts/42?id=evil{id:"42"} test).

All four serialization sites covered. dev SSR (dev-server.ts:1662), dev ISR regen (dev-server.ts:1233), and the shared buildPagesNextDataScript (pages-page-response.ts:303, used by prod render + prod ISR regen). No query: params site was missed.

ISR safety confirmed. renderPagesIsrHtml is only reached for the gsp path, where computePagesNextDataQuery returns params regardless of query, so no request-specific querystring leaks into cached HTML. The path-only cache key is fine.

Minor nits (non-blocking)

  1. PR description says "helper unit test: 6/6 passing" but tests/pages-readiness.test.ts contains only 5 tests (the gSSP and gIP cases are identical inputs/duplicate assertions). The integration block adds 8 tests, not 6. Just a count discrepancy in the description — no code impact.

  2. The gIP unit test duplicates the gSSP unit test exactly (same inputs). Since the helper only distinguishes on isFallback/autoExport/gsp, a gIP page is structurally identical to gSSP at the helper level. The integration test (nav-compat-gip) is what actually exercises the gIP classification path, so this is fine — just noting the unit-level duplication is cosmetic.

No blocking issues. Nice, surgical fix with thorough test coverage and clear documentation of the Next.js parity reasoning.

Comment thread tests/pages-readiness.test.ts Outdated
).toEqual({ id: "42", q: "foo" });
});

it("returns the full merged query for getInitialProps pages", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this case has identical inputs to the gSSP test above (gsp: undefined, autoExport: false), so it doesn't exercise a distinct branch in computePagesNextDataQuery — gIP classification only matters upstream in buildPagesReadinessNextData. The integration test against the nav-compat-gip fixture is what actually covers the gIP path. Harmless, just noting the unit-level redundancy.

@ask-bonk

ask-bonk Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully to PR #2060.

Summary

I reviewed PR #2060 (fix(pages): serialize full merged query into SSR __NEXT_DATA__.query) by checking out the branch, reading the relevant source, and running the tests. The fix is correct and well-tested.

Verification performed

  • Client/server parity: Confirmed getRouteQueryFromNextData (shims/router.ts:1201) only reads params from __NEXT_DATA__.query in the rewrite case (path ≠ pattern), so adding the querystring doesn't break useRouter().query.
  • autoExport/fallback carve-out: Verified the rewrite-navigation fixture is autoExport and that keeping params (instead of Next.js's {}) is necessary so the client can recover route params after a rewrite. The new regression test guards this.
  • All serialization sites covered: dev SSR, dev ISR regen, and the shared buildPagesNextDataScript (prod render + prod ISR). No query: params site missed.
  • ISR safety: renderPagesIsrHtml only runs for gsp pages, where the helper returns params — no querystring leaks into cached HTML.
  • Tests: All 8 new integration tests pass, 5 unit tests pass, the getStaticProps carve-out regression guard stays green, and vp check is clean. (The one failing test, includes __NEXT_DATA__ in exported HTML, is a pre-existing artifact of running with a -t filter that skips the export build step — unrelated to this PR.)

Minor nits raised (non-blocking)

  1. PR description claims "6/6 passing" for the helper unit test, but there are actually 5 tests (gSSP and gIP cases are identical).
  2. The gIP unit test duplicates the gSSP one exactly; the real gIP coverage comes from the integration test.

No blocking issues — a surgical, well-documented fix.

github run

@github-actions

github-actions Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 11a6961 against base 8d0a18d using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 126.6 KB 126.6 KB ⚫ 0.0%
Client entry size (gzip) vinext 120.6 KB 120.6 KB ⚫ 0.0%
Dev server cold start vinext 2.72 s 2.72 s ⚫ +0.3%
Production build time vinext 2.96 s 2.97 s ⚫ +0.3%
RSC entry closure size (gzip) vinext 98.6 KB 98.6 KB ⚫ -0.0%
Server bundle size (gzip) vinext 165.3 KB 165.3 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@Divkix

Divkix commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the two nits from the review: removed the duplicate gIP unit test (identical to gSSP case — real gIP coverage is in the integration test), and fixed the PR description counts (5/5 unit tests, 8 integration tests).

Divkix added 3 commits July 12, 2026 22:48
…loudflare#1970)

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.
…xport renders

The initial cloudflare#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 cloudflare#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.
@Divkix Divkix force-pushed the fix/issue-1970-next-data-query branch from 8e5ba0f to 11a6961 Compare July 13, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pages Router: __NEXT_DATA__.query omits the querystring on SSR (route params only)

2 participants