feat(pages-router): implement _next/data JSON endpoint#1384
Conversation
Recognize `/_next/data/<buildId>/<page>.json` in dev and prod request
pipelines. The URL is normalized to the page path BEFORE middleware
runs (matching Next.js' `handleNextDataRequest`), and the server emits
`{ pageProps }` as JSON with `Content-Type: application/json` instead
of the rendered HTML.
Closes #1330
commit: |
|
|
/bigbonk review |
There was a problem hiding this comment.
Review: _next/data JSON endpoint implementation
This is a well-structured PR. The helper is cleanly separated, the dev/prod paths are symmetric, and the test coverage is solid. A few things to address:
Issues
1. getStaticProps data requests bypass ISR cache but don't emit JSON (pages-page-data.ts:367-384, pages-page-data.ts:387-451)
The ISR cache bypass for isDataReq is correct — you can't serve cached HTML as JSON. But the current logic only short-circuits to JSON in the generated entry's _renderPage for gSSP pages (lines 617-629 of pages-server-entry.ts). For getStaticProps pages, resolvePagesPageData falls through to kind: "render", and then _renderPage hits the isDataReq branch and emits JSON.
This works, but there's a subtle gap: when getStaticProps returns notFound: true, the current code calls buildPagesDataNotFoundResponse() (which returns application/json with {}), but when getStaticPaths with fallback: false rejects the path, it calls buildPagesNotFoundResponse() which returns text/html. For a data request against a fallback: false path that isn't listed, the client would get HTML instead of JSON.
Consider gating buildPagesNotFoundResponse() at pages-page-data.ts:304-307:
if (!isValidPath) {
return {
kind: "response",
response: options.isDataReq ? buildPagesDataNotFoundResponse() : buildPagesNotFoundResponse(),
};
}2. Dev server: isDataReq for getStaticProps ISR cache HIT/STALE paths short-circuits to HTML (dev-server.ts:587-606, dev-server.ts:609-615)
In dev-server.ts, the ISR cache HIT and STALE code paths at lines 587-606 and 609-615 have !isDataReq guards that skip the cached HTML response for data requests (good). But the subsequent getStaticProps MISS path at line 762+ doesn't have a JSON short-circuit before the full render. The isDataReq JSON short-circuit at line 814 covers this since it runs after getStaticProps completes and pageProps is populated. This works correctly but it's worth verifying that the ISR cache-fill path (lines 1059-1082) is also correctly skipped for data requests — it appears to be, since isrRevalidateSeconds is set correctly.
Confirmed: this path is fine.
3. Dev server hardcodes "development" as fallback buildId (index.ts:2867)
const devBuildId = process.env.__VINEXT_BUILD_ID ?? "development";This is fine for the common case, but the test uses this same pattern:
const DEV_BUILD_ID = process.env.__VINEXT_BUILD_ID ?? "development";The generated server entry embeds buildId from nextConfig.buildId. If next.config.mjs sets generateBuildId, the dev server index.ts fallback "development" won't match the entry's actual buildId. This isn't a regression (the endpoint didn't exist before), but it's a parity gap with how prod handles it. The prod server reads pagesBuildId from the server entry's export const buildId — the dev server should similarly read the resolved buildId from the plugin's nextConfig.buildId rather than relying on the define.
4. Missing _next/data test for getStaticProps page (both dev and prod)
All integration tests use /ssr which has getServerSideProps. Next.js also serves _next/data responses for getStaticProps pages (the client router uses it for any page with a data-fetching method). Adding a test for e.g. /_next/data/<buildId>/blog/hello-world.json (which has getStaticProps + getStaticPaths) would strengthen confidence that the GSP path also emits JSON correctly.
5. Entry template: inline JSON envelope logic could be extracted (pages-server-entry.ts:611-629)
Per AGENTS.md guidance ("generated code should stay thin"), the JSON envelope construction at lines 611-629 duplicates some of what buildNextDataJsonResponse already does. The generated entry builds its own Response manually rather than calling __buildNextDataJsonResponse. This means:
- The
pages-data-route.tshelperbuildNextDataJsonResponseis only used in tests (never actually called in production code paths). - If the envelope shape changes, two places need updating.
Consider using __buildNextDataJsonResponse (which is already imported in the entry) here instead, passing the gsspRes.headers as init.headers.
Nits
6. pages-data-route.ts:76 — The endsWith("/index") check is correct but could also match a page literally named "index" nested under another segment (e.g., /_next/data/<buildId>/blog/index.json → /blog instead of /blog/index). This matches Next.js behavior (denormalizePagePath), just noting it as intentional.
7. The dev test uses process.env.__VINEXT_BUILD_ID ?? "development" which works because the test runs in the same Node process as the dev server. But this is fragile — if the dev server starts using the resolved nextConfig.buildId (per point 3 above), the test would need updating. Consider extracting the dev buildId from the server entry module or having the fixture's next.config.mjs pin a known buildId.
What looks good
- Clean separation:
pages-data-route.tsis well-documented, focused, and independently unit-tested. - Dev/prod parity: Both paths normalize the URL before middleware, both pass
isDataReqthrough, both handle stale buildId as JSON 404. - Middleware visibility test is exactly the right assertion — proving middleware sees the page path, not the raw
_next/dataURL. - ISR cache bypass is correct: data requests skip cached HTML and don't pollute the HTML cache with JSON.
- The prod server handles stale buildId early (before middleware), which is the right thing to do.
| // and cookies set on the gsspRes by getServerSideProps are forwarded | ||
| // so middleware/auth flows work the same as the HTML page. | ||
| if (isDataReq) { | ||
| const dataHeaders = { "Content-Type": "application/json" }; |
There was a problem hiding this comment.
This manually constructs the JSON response inline instead of calling __buildNextDataJsonResponse (which is imported on line 215 but never used in production code). This creates two code paths that must stay in sync if the envelope shape changes.
Consider:
if (isDataReq) {
const init = {};
if (gsspRes && gsspRes.headers) {
init.headers = {};
for (const [k, v] of Object.entries(gsspRes.headers)) {
if (v === undefined || v === null) continue;
init.headers[k] = Array.isArray(v) ? v.join(", ") : String(v);
}
}
return __buildNextDataJsonResponse(pageProps, safeJsonStringify, init);
}This also keeps the generated entry thin (per AGENTS.md: "generated code should delegate to typed helpers").
| * subsequent HTML hits. Next.js handles this the same way — see | ||
| * `isNextDataRequest` checks in `packages/next/src/server/base-server.ts`. | ||
| */ | ||
| isDataReq?: boolean; |
There was a problem hiding this comment.
Bug: getStaticPaths with fallback: false (line ~290-308 of this file, outside the diff) calls buildPagesNotFoundResponse() which returns text/html. For data requests (isDataReq: true), the client expects application/json. This needs to be gated:
if (!isValidPath) {
return {
kind: "response",
response: options.isDataReq ? buildPagesDataNotFoundResponse() : buildPagesNotFoundResponse(),
};
}Without this fix, router.push("/blog/unlisted-path") on a page with fallback: false will get an HTML 404 instead of a JSON 404, potentially causing a client-side parse error.
The same issue exists in the generated entry at pages-server-entry.ts line ~480 where __buildNextDataNotFoundResponse() is only called when !match (no route found at all), but not when getStaticPaths rejects the path.
| // to the dot-extension skip below which returns 404. | ||
| let isDataReq = false; | ||
| if (isNextDataPathname(pathname)) { | ||
| const devBuildId = process.env.__VINEXT_BUILD_ID ?? "development"; |
There was a problem hiding this comment.
Nit: the fallback "development" here works in practice, but it's a different resolution mechanism than prod (which reads pagesBuildId from the server entry's export const buildId). If next.config.mjs sets generateBuildId, this fallback won't match the resolved buildId from nextConfig.buildId. Consider reading nextConfig.buildId from the plugin's resolved config (which is available in scope here) instead of relying on the compile-time define.
|
Review posted successfully. Here's a summary of what I found: Review Summary for PR #1384Posted a detailed review comment and 3 inline comments on the PR. Key findings:
What looks good:
|
- Delegate JSON envelope construction from the generated entry to the typed `buildNextDataJsonResponse` helper so the envelope shape lives in one place (entries stay thin per AGENTS.md). - Return a JSON 404 from `resolvePagesPageData` when `getStaticPaths` with `fallback: false` rejects the path for a data request. Without this, client-side navigations to unlisted slugs got an HTML 404 body which fails to parse as JSON in the Next.js client router. - Apply the same JSON-404 gating in the dev server for `getStaticPaths fallback:false`, `getServerSideProps.notFound`, and `getStaticProps.notFound` paths. - Read the dev buildId from the plugin's resolved `nextConfig.buildId` first so a user-supplied `generateBuildId` is honored, matching the value embedded into the prod server entry. - Add coverage for `getStaticProps` data requests and the `fallback:false` JSON-404 path.
|
/bigbonk review |
There was a problem hiding this comment.
Re-review: All previous findings addressed
The fix commit (07bfd5d) addresses all five findings from the previous review:
- Entry template delegation —
_renderPagenow calls__buildNextDataJsonResponseinstead of constructing the JSON response inline. The envelope shape lives in one place. getStaticPathsfallback:falseJSON 404 —pages-page-data.ts:303-313now gates onisDataReq, returningbuildPagesDataNotFoundResponse()for data requests andbuildPagesNotFoundResponse()for HTML.- Dev buildId resolution —
index.ts:2872-2873readsnextConfig?.buildIdfirst, falling back to the env var and"development". Tests now use the fixture'sgenerateBuildIdvalue ("test-build-id"). - Missing
getStaticPropstest coverage — Added for both dev and prod. - Dev server notFound guards —
isDataReqchecks added forgSSP.notFound(line 526),gSP.notFound(line 801), andgetStaticPaths fallback:false(line 448) indev-server.ts.
Two items to track as follow-ups (not blocking)
1. Dev server: stale buildId returns HTML, not JSON 404
When isNextDataPathname matches but parseNextDataPathname returns null (wrong buildId), the dev server falls through to the dot-extension check at index.ts:2889 which calls next() — Vite serves an HTML 404. The prod server (prod-server.ts:1570-1578) correctly returns a JSON 404 via buildNextDataNotFoundResponse(). This is a dev/prod parity gap. There's a prod test for stale buildId but no corresponding dev test. Low priority since stale buildIds in dev are uncommon (the client and server restart together), but worth aligning for consistency.
2. pages-page-data.ts: gSSP.notFound and gSP.notFound return JSON for all requests in prod
Lines 354-358 and 479-483 unconditionally call buildPagesDataNotFoundResponse() regardless of isDataReq. For HTML navigations through the prod entry where gSSP/gSP returns { notFound: true }, the response is application/json {} instead of an HTML 404 page. The dev server is fine — it handles notFound before calling resolvePagesPageData with its own isDataReq-gated logic. This is technically a pre-existing bug (before this PR it returned plain text "404" which was also wrong for HTML), but the change from text/plain to application/json makes it slightly more visible. The fix is straightforward: gate on options.isDataReq at both call sites, same as the getStaticPaths path at line 309.
What's good
- Clean helper (
pages-data-route.ts) — well-documented, independently tested, no unnecessary coupling. - Dev/prod symmetry in the normalization-before-middleware pipeline.
- ISR cache bypass is correct and well-reasoned.
- The middleware visibility test (
x-mw-pathname) is the right assertion for proving the normalization order. - Stale buildId handling in prod returns JSON 404 before middleware, which is correct.
- The entry stays thin — delegates to typed helpers as AGENTS.md prescribes.
Approving. The two items above are minor and can be addressed in a follow-up.
| // path, not the raw _next/data URL. | ||
| req.url = url; | ||
| } | ||
| } |
There was a problem hiding this comment.
Nit (follow-up): When isNextDataPathname matches but parseNextDataPathname returns null (stale buildId), isDataReq stays false and the pathname still contains .json, so the dot-extension check at line 2889 calls next() — Vite returns an HTML 404. The prod server handles this correctly with buildNextDataNotFoundResponse() at prod-server.ts:1575.
For consistency, consider adding a JSON 404 early return here:
if (isNextDataPathname(pathname)) {
const devBuildId = nextConfig?.buildId ?? process.env.__VINEXT_BUILD_ID ?? "development";
const dataMatch = parseNextDataPathname(pathname, devBuildId);
if (dataMatch) {
isDataReq = true;
// ... existing normalization
} else {
// Stale buildId — return JSON 404 matching prod behavior
res.writeHead(404, { "Content-Type": "application/json" });
res.end("{}");
return;
}
}Low priority since stale buildIds in dev are rare (client/server restart together).
| * subsequent HTML hits. Next.js handles this the same way — see | ||
| * `isNextDataRequest` checks in `packages/next/src/server/base-server.ts`. | ||
| */ | ||
| isDataReq?: boolean; |
There was a problem hiding this comment.
Pre-existing issue surfaced by this PR: buildPagesDataNotFoundResponse() is called unconditionally at lines 357 and 482 (for gSSP.notFound and gSP.notFound respectively), regardless of isDataReq. For HTML navigations through the prod entry where gSSP/gSP returns { notFound: true }, the user gets application/json {} instead of an HTML 404 page.
The dev server is fine — it handles notFound before reaching resolvePagesPageData. But the prod entry calls resolvePagesPageData and returns the response directly without checking the request type.
The fix matches the getStaticPaths path at line 309:
if (result?.notFound) {
return {
kind: "response",
response: options.isDataReq
? buildPagesDataNotFoundResponse()
: buildPagesNotFoundResponse(),
};
}Same change needed at both line 357 and 482.
This was already wrong before this PR (returned plain text "404" for all requests), so not blocking — but the change to application/json makes it more visible and worth fixing as a follow-up.
|
Review posted on PR #1384. Here's a summary: Verdict: Approved. All five findings from the previous review have been addressed in commit Two non-blocking follow-ups identified:
|
…gSP notFound on HTML nav Follow-ups from bigbonk round 2: - Dev request pipeline now returns a JSON 404 when `_next/data/<wrong>` is hit, mirroring the prod-server path. Previously the dev fall-through served Vite's HTML 404, breaking client routers that try to parse the body as JSON. - `resolvePagesPageData` now gates the gSSP/gSP `notFound: true` 404 body on `isDataReq` so HTML navigations get the HTML 404 page and data requests get the JSON envelope. Previously both shapes always returned the JSON body, which surfaced text-content-type mismatches on HTML navigations through gSSP/gSP `notFound`.
|
/bigbonk review |
There was a problem hiding this comment.
Re-review: Both follow-ups addressed — approving
Commit e245bde addresses both remaining items from the prior review:
-
Dev stale buildId now returns JSON 404 —
index.ts:2884-2889returnsres.writeHead(404, {"Content-Type": "application/json"})+res.end("{}")whenparseNextDataPathnamereturns null, matching the prod path. Test added at line 1176. -
gSSP.notFoundandgSP.notFoundnow gate onisDataReq—pages-page-data.ts:354-360and481-486use the ternaryoptions.isDataReq ? buildPagesDataNotFoundResponse() : buildPagesNotFoundResponse(), so HTML navigations get the HTML 404 page and data requests get JSON. Dev server handles the same cases atdev-server.ts:526(gSSP) anddev-server.ts:801(gSP).
What I verified
- Dev/prod parity: Both paths normalize
_next/databefore middleware, both return JSON 404 for stale buildId, both passisDataReqthrough the render pipeline. The generated entry delegates to__buildNextDataJsonResponse(line 627) keeping it thin per AGENTS.md. - ISR cache bypass: All four ISR cache hit/stale guards (
pages-page-data.ts:374-380,394-399,dev-server.ts:598-603,621-626) include!isDataReq, correctly falling through to fresh data fetching. getStaticPathsfallback:false: Gated onisDataReqatpages-page-data.ts:309anddev-server.ts:448.- Test coverage: 14 unit tests for the helper, 7 dev integration tests (gSSP, gSP, middleware visibility, unknown page, fallback:false, stale buildId), 5 prod integration tests. Good coverage.
One non-blocking observation for future work
gSSP/gSP redirect returns HTTP 3xx for data requests — In Next.js, when getServerSideProps or getStaticProps returns { redirect } during a _next/data request, the server returns a JSON body with __N_REDIRECT and __N_REDIRECT_STATUS keys instead of an HTTP redirect. This lets the client router handle the redirect client-side without the browser following it transparently (which would lose the JSON content-type on the redirect target). The current code at pages-page-data.ts:344-351 and dev-server.ts:510-523 returns a raw HTTP redirect for both HTML and data requests. This is a pre-existing gap (the redirect behavior existed before this PR), so not blocking — just noting it for parity tracking.
|
The inline comment API is tricky — the redirect observation is already covered in the review body, so that's sufficient. Review posted and approved on PR #1384. Here's the summary: Review SummaryVerdict: Approved. All findings from the two previous review rounds have been addressed across the 4 commits. What was verified:
One non-blocking observation for future work:
|
…1412) * feat(pages-router): consume _next/data JSON endpoint from the client The server-side `/_next/data/<buildId>/<page>.json` endpoint landed in #1384, but the vinext client was still fetching the full HTML page on every navigation and regex-extracting `__NEXT_DATA__` from it. This wires the client to actually use the JSON endpoint, matching what Next.js does and avoiding the HTML round trip. ## Client navigation (shims/router.ts) `navigateClient` is split into two paths: - `navigateClientData` (new): when `window.__VINEXT_PAGE_LOADERS__` has a registered code-split loader for the target route AND we have a buildId, fetch `/_next/data/<buildId>/<page>.json` with `Accept: application/json` + `x-nextjs-data: 1`. On 200 parse the envelope, look up the page module via the in-memory loader map (Vite has already split each page into its own chunk), and re-render. - `navigateClientHtml`: the previous HTML-fetch + regex-extract logic, unchanged. Used in dev (where the inline hydration script does not populate the loader map) and as a fallback for any route not in the client loader map. Failure modes — 404, 5xx, network error, malformed JSON, soft redirect via `x-nextjs-redirect`, missing loader — all queue a hard navigation (`window.location.href = url`). This is the deploy-skew safety net: when the server's buildId has rotated, the data endpoint returns 404 and the client lands on the new build via a full document load. ## Code-split loader manifest (entries/pages-client-entry.ts) Vite already code-splits each page into its own chunk via the dynamic imports in `pageLoaders`. The generated client entry now exposes that map on `window.__VINEXT_PAGE_LOADERS__` (route pattern → loader thunk), plus `window.__VINEXT_PAGE_PATTERNS__` (ordered list) and `window.__VINEXT_APP_LOADER__` (the `_app` thunk, when present). This replaces Next.js' `_buildManifest.js` indirection — vinext already generates the equivalent at build time, we just had to expose it. ## Data URL construction (shims/internal/pages-data-url.ts) New pure helper module mirroring `getAssetPathFromRoute` + `getDataHref` from Next.js. Handles: - `/` → `/_next/data/<id>/index.json` - `/about` → `/_next/data/<id>/about.json` - `/index` → `/_next/data/<id>/index/index.json` (round-trip) - `/blog/foo` → `/_next/data/<id>/blog/foo.json` Also exports `matchPagesPattern` which uses the existing `matchRoutePattern` to map a URL pathname to a registered route pattern. Round-trip tests verify the server's `parseNextDataPathname` agrees with the client's `buildPagesDataPath` for every shape, and surfaced a latent server bug: `/_next/data/<id>/index/foo.json` was parsing back to `/index/foo` instead of `/foo`. Fixed in `pages-data-route.ts` so the round-trip is now lossless. ## Prefetch (shims/link.tsx + shims/router.ts) `<Link>` prefetch and `Router.prefetch()` now prefer the data path: - Inject `<link rel="prefetch" as="fetch" crossorigin href={dataUrl}>` for the JSON, matching the actual navigation's request shape. - Invoke the loader thunk to warm the chunk in parallel. Vite's `import()` caches the result, so the eventual click is a double cache hit. When no loader matches (dev, unmapped route), fall back to the legacy `<link rel="prefetch" as="document">` so the browser at least preloads the HTML. Notably this also fixes a long-standing prefetch bug: the previous Pages branch of `link.tsx`'s prefetch check (`__NEXT_DATA__.__vinext?.pageModuleUrl`) was only populated by the dev server's inline hydration script, so prefetch in production was a no-op. ## Tests - `tests/pages-data-url.test.ts` (new): 27 tests covering URL construction and the client/server round trip. - `tests/shims.test.ts` (+8): JSON-path navigation success, 404 hard-reload (build-skew), `x-nextjs-redirect` soft-redirect handling, HTML fallback when no loader matches, dynamic param extraction, malformed JSON hard-reload, prefetch JSON path, prefetch fallback. - Ports from Next.js: `getDataHref` in `.nextjs-ref/packages/next/src/client/page-loader.ts:154`, `fetchNextData` in `.nextjs-ref/packages/next/src/shared/lib/router/router.ts:498`, `getAssetPathFromRoute` in `.nextjs-ref/packages/next/src/shared/lib/router/utils/get-asset-path-from-route.ts`. Verified: 1393 vitest tests pass across shims, pages-router, link/navigation, prefetch, data-route, data-url, entry-templates, and routing. Closes #1330 follow-up. * fix(pages-router): drop unused export on PagesPatternMatch Knip flagged the exported `PagesPatternMatch` type as unused. It is only referenced as the return type of `matchPagesPattern` in the same file, so narrow it to a local type. * fix(pages-router): merge search params into __NEXT_DATA__.query on data-path nav bigbonk review found that navigateClientData was synthesising __NEXT_DATA__.query from dynamic route params only, missing the URL search params. Code reading window.__NEXT_DATA__.query directly (a public Next.js surface) would see incomplete data after a JSON navigation, even though useRouter().query happened to still work because getPathnameAndQuery() re-reads window.location.search. Parse target.search and merge it into query alongside the route params, with route params winning on key collision — same merge order as getPathnameAndQuery() and Next.js itself. Also fix a misleading comment in pages-client-entry.ts: the patterns list is already sorted by pagesRouter()/compareRoutes (static → dynamic → catch-all), not by the router at runtime. * test(pages-router): e2e regression guard for JSON nav path Adds a Playwright assertion that a Link click on a hydrated Pages Router app on Cloudflare Workers fires a request to /_next/data/<buildId>/<page>.json and does NOT trigger a full HTML reload. If the build pipeline ever stops exposing __VINEXT_PAGE_LOADERS__ (or the loader map mis-keys), every other navigation test still passes — they all assert the URL changed and the new page rendered, neither of which fails when navigateClient silently falls back to the HTML path. This test fails loudly in that scenario. * test(pages-router): use hydration timestamp instead of resource-type sniffing The original assertion 'no document-resource request to /ssr' caught false positives: <link rel="prefetch" as="document"> can fire on initial paint for unrelated reasons, and Chromium does not always classify prefetch hints in a stable way. Swap to checking that __VINEXT_HYDRATED_AT survives the click — a document reload would reset it, an in-place re-render preserves it. Combined with the data-URL network assertion, this remains a tight guard on the JSON nav path without the flake. * test(pages-router): use window sentinel instead of timestamp comparison Timestamps are timing-related signals; swap to a window-scoped sentinel that a document reload would wipe and an in-place re-render preserves. Same guarantee, no flake. * fix(pages-router): handle _next/data requests in Cloudflare Workers example The Node prod-server normalizes /_next/data/<buildId>/<page>.json before middleware and flags it as a data request so renderPage emits the JSON envelope. The Cloudflare worker example never did — data requests hit matchPageRoute which can't match /_next/data/... and returned 404, so navigateClientData saw the 404 and triggered a hard navigation. The client-side JSON nav path was effectively dead on Workers. Mirror the prod-server normalization in the worker: detect the data path before middleware, validate the buildId, strip it to the page path, and pass { isDataReq: true } through to renderPage. Export ./server/pages-data-route from the vinext package so user-written workers can import the helpers. * Revert "fix(pages-router): handle _next/data requests in Cloudflare Workers example" This reverts commit 719575b. * fix(pages-router): normalize _next/data requests inside the server entry The Node prod-server normalized /_next/data/<buildId>/<page>.json before middleware so renderPage could emit the JSON envelope. Cloudflare Workers go through a user-written worker entry that doesn't (and shouldn't) know about the data endpoint protocol, so data requests were reaching matchPageRoute as /_next/data/... URLs, returning 404, and the client fell back to a hard navigation — defeating the whole JSON-path optimisation on Workers. Move the detection into the generated server entry so it works for any caller. runMiddleware now normalizes the URL for middleware matcher and signals via result.rewriteUrl, and renderPage auto-detects data requests from request.url so the JSON envelope fires whether or not middleware rewrote the URL. Mismatched buildId short-circuits to a JSON 404 in both paths so stale clients hard-navigate onto the new build. * chore: re-trigger CI * refactor(pages-router): share data-nav target resolver between Link and Router Extract `resolvePagesDataNavigationTarget` + `prefetchPagesData` into `shims/internal/pages-data-target.ts` so the Link prefetch path and the Router (`navigateClient` + `Router.prefetch`) share one decision helper instead of maintaining two structurally identical copies. Also strip the locale prefix before matching `__VINEXT_PAGE_PATTERNS__` so locale transitions (e.g. `/fr/about`) hit the JSON data fast path instead of falling through to the slower HTML round-trip — route patterns are locale-unaware (`/about`), but the browser URL for a locale-prefixed page includes the locale. The data URL itself keeps the locale prefix because the server uses it to pick locale-specific gSSP data. When the data path is used and the URL implies a different locale than the current `__NEXT_DATA__.locale`, refresh the locale fields on the synthesised `__NEXT_DATA__` (the JSON envelope itself only carries `pageProps`, so the locale must be derived from the URL). Without this, `useRouter().locale` stays stale after a client-side locale transition. * chore: re-trigger CI
Summary
Implements the
/_next/data/<buildId>/<page>.jsonendpoint for the Pages Router in both dev and prod, so client-side navigations vianext/linkandrouter.push()work end-to-end on vinext.packages/vinext/src/server/pages-data-route.tsparses the URL pattern and builds the JSON envelope. Unit-tested intests/pages-data-route.test.ts.index.ts, prodprod-server.ts) detects_next/dataafter basePath/trailing-slash normalization and rewrites the URL to the page path BEFORE middleware runs — matching Next.js'handleNextDataRequest(packages/next/src/server/base-server.ts).renderPage(generated entry) accepts anisDataReqoption. When set, it skips React tree +_documentshell rendering and emits the JSON envelope directly. Headers set bygetServerSidePropsare preserved.resolvePagesPageDatabypasses the HTML ISR cache for data requests (a cached HTML body cannot be reshaped into JSON; storing JSON in the HTML cache would corrupt subsequent HTML hits) and now emits a JSON-shaped 404 body so naive clients callingres.json()do not throw before checking the status.JSON envelope shape
{ "pageProps": { "message": "Hello from getServerSideProps" } }Content-Type: application/json; status 200 for matched pages, 404 (with{}body) for unknown pages or a stale buildId.Middleware visibility
The pages-basic fixture middleware now sets
x-mw-pathname: <observed pathname>. The new tests assert that requesting/_next/data/<buildId>/ssr.jsonmakes middleware observe/ssr— proving the normalization happens before middleware runs.Test plan
pnpm test tests/pages-data-route.test.ts(14 new unit tests)pnpm test tests/pages-router.test.ts(216 tests, including 3 new dev + 4 new prod integration tests for the endpoint)pnpm test tests/pages-page-data.test.ts tests/pages-i18n.test.ts tests/pages-i18n-prod.test.tspnpm test tests/app-router.test.ts(318 tests — no regressions)pnpm run check(format, lint, types green)References
base-server.tshandleNextDataRequestrender.tsxisNextDataRequestbranchtest/e2e/middleware-general/test/index.test.ts(should trigger middleware for data requests,should normalize data requests into page requests)Closes #1330