Skip to content

feat(pages-router): implement _next/data JSON endpoint#1384

Merged
james-elicx merged 4 commits into
mainfrom
fix/issue-1330-next-data-endpoint
May 21, 2026
Merged

feat(pages-router): implement _next/data JSON endpoint#1384
james-elicx merged 4 commits into
mainfrom
fix/issue-1330-next-data-endpoint

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary

Implements the /_next/data/<buildId>/<page>.json endpoint for the Pages Router in both dev and prod, so client-side navigations via next/link and router.push() work end-to-end on vinext.

  • New helper packages/vinext/src/server/pages-data-route.ts parses the URL pattern and builds the JSON envelope. Unit-tested in tests/pages-data-route.test.ts.
  • Request pipeline (dev index.ts, prod prod-server.ts) detects _next/data after 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 an isDataReq option. When set, it skips React tree + _document shell rendering and emits the JSON envelope directly. Headers set by getServerSideProps are preserved.
  • resolvePagesPageData bypasses 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 calling res.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.json makes 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.ts
  • pnpm test tests/app-router.test.ts (318 tests — no regressions)
  • pnpm run check (format, lint, types green)
  • CI green

References

Closes #1330

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
@pkg-pr-new

pkg-pr-new Bot commented May 20, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1384

commit: e245bde

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@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: _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.ts helper buildNextDataJsonResponse is 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.ts is well-documented, focused, and independently unit-tested.
  • Dev/prod parity: Both paths normalize the URL before middleware, both pass isDataReq through, 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/data URL.
  • 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" };

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.

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;

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.

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.

Comment thread packages/vinext/src/index.ts Outdated
// to the dot-extension skip below which returns 404.
let isDataReq = false;
if (isNextDataPathname(pathname)) {
const devBuildId = process.env.__VINEXT_BUILD_ID ?? "development";

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

@ask-bonk

ask-bonk Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of what I found:

Review Summary for PR #1384

Posted a detailed review comment and 3 inline comments on the PR.

Key findings:

  1. Bug: getStaticPaths with fallback: false returns HTML instead of JSON for data requests — When a client-side navigation hits a path not listed in getStaticPaths, the 404 response is text/html instead of application/json. This could cause a parse error in the client router. Filed as inline comment on pages-page-data.ts.

  2. Generated entry duplicates JSON envelope logic — The _renderPage function in pages-server-entry.ts manually constructs the JSON response instead of calling the already-imported __buildNextDataJsonResponse helper. This violates the AGENTS.md principle that generated entries should delegate to typed helpers. Filed as inline comment.

  3. Dev server buildId resolution is fragile — The dev server uses process.env.__VINEXT_BUILD_ID ?? "development" as a fallback, which won't match when generateBuildId is configured in next.config.mjs. The plugin's resolved nextConfig.buildId is available in scope and should be used instead. Filed as inline comment on index.ts.

  4. Missing test coverage for getStaticProps pages — All integration tests use /ssr (which has getServerSideProps). Adding a test for a getStaticProps page (e.g., /blog/hello-world.json) would strengthen coverage.

What looks good:

  • Clean helper separation in pages-data-route.ts with thorough unit tests
  • Dev/prod parity in URL normalization before middleware
  • ISR cache bypass is correctly implemented
  • Middleware visibility test correctly validates the normalization behavior
  • Stale buildId handling returns JSON 404 early in prod (before middleware)

github run

- 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.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@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.

Re-review: All previous findings addressed

The fix commit (07bfd5d) addresses all five findings from the previous review:

  1. Entry template delegation_renderPage now calls __buildNextDataJsonResponse instead of constructing the JSON response inline. The envelope shape lives in one place.
  2. getStaticPaths fallback:false JSON 404pages-page-data.ts:303-313 now gates on isDataReq, returning buildPagesDataNotFoundResponse() for data requests and buildPagesNotFoundResponse() for HTML.
  3. Dev buildId resolutionindex.ts:2872-2873 reads nextConfig?.buildId first, falling back to the env var and "development". Tests now use the fixture's generateBuildId value ("test-build-id").
  4. Missing getStaticProps test coverage — Added for both dev and prod.
  5. Dev server notFound guardsisDataReq checks added for gSSP.notFound (line 526), gSP.notFound (line 801), and getStaticPaths fallback:false (line 448) in dev-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;
}
}

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 (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;

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.

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.

@ask-bonk

ask-bonk Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #1384. Here's a summary:

Verdict: Approved. All five findings from the previous review have been addressed in commit 07bfd5d.

Two non-blocking follow-ups identified:

  1. Dev server stale buildId returns HTML, not JSON 404 (index.ts:2884) — When parseNextDataPathname returns null for a wrong buildId, the dev server falls through to Vite's static file handling (HTML 404) instead of returning a JSON 404 like prod does. Low priority since stale buildIds in dev are rare.

  2. pages-page-data.ts: gSSP.notFound and gSP.notFound unconditionally return JSON (lines 357, 482) — These call buildPagesDataNotFoundResponse() without checking isDataReq, so prod HTML navigations where gSSP/gSP returns { notFound: true } get application/json instead of an HTML 404 page. Pre-existing bug (was plain text "404" before), but now slightly more visible. Straightforward fix: gate on options.isDataReq like the getStaticPaths path at line 309.

github run

…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`.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review

@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.

Re-review: Both follow-ups addressed — approving

Commit e245bde addresses both remaining items from the prior review:

  1. Dev stale buildId now returns JSON 404index.ts:2884-2889 returns res.writeHead(404, {"Content-Type": "application/json"}) + res.end("{}") when parseNextDataPathname returns null, matching the prod path. Test added at line 1176.

  2. gSSP.notFound and gSP.notFound now gate on isDataReqpages-page-data.ts:354-360 and 481-486 use the ternary options.isDataReq ? buildPagesDataNotFoundResponse() : buildPagesNotFoundResponse(), so HTML navigations get the HTML 404 page and data requests get JSON. Dev server handles the same cases at dev-server.ts:526 (gSSP) and dev-server.ts:801 (gSP).

What I verified

  • Dev/prod parity: Both paths normalize _next/data before middleware, both return JSON 404 for stale buildId, both pass isDataReq through 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.
  • getStaticPaths fallback:false: Gated on isDataReq at pages-page-data.ts:309 and dev-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.

@ask-bonk

ask-bonk Bot commented May 20, 2026

Copy link
Copy Markdown
Contributor

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 Summary

Verdict: Approved. All findings from the two previous review rounds have been addressed across the 4 commits.

What was verified:

  • Both follow-up items resolved in commit e245bde:

    1. Dev stale buildId now returns JSON 404 (index.ts:2884-2889) matching prod behavior, with a corresponding test
    2. gSSP.notFound and gSP.notFound now gate on isDataReq in pages-page-data.ts (lines 354-360 and 481-486), returning HTML 404 for HTML navigations and JSON 404 for data requests
  • Dev/prod parity confirmed across index.ts, dev-server.ts, prod-server.ts, and pages-server-entry.ts

  • ISR cache bypass correctly implemented with !isDataReq guards at all 4 cache hit/stale check sites

  • Generated entry stays thin — delegates to __buildNextDataJsonResponse helper per AGENTS.md

  • Test coverage is solid — 14 unit tests + 12 integration tests (dev + prod)

One non-blocking observation for future work:

gSSP/gSP redirects during data requests return HTTP 3xx instead of Next.js's JSON __N_REDIRECT envelope. This is pre-existing (not introduced by this PR) but worth tracking for full client-side navigation parity.

github run

@james-elicx
james-elicx marked this pull request as ready for review May 20, 2026 20:22
@james-elicx
james-elicx merged commit 576c55d into main May 21, 2026
36 checks passed
@james-elicx
james-elicx deleted the fix/issue-1330-next-data-endpoint branch May 21, 2026 14:55
james-elicx added a commit that referenced this pull request May 22, 2026
…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
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 JSON endpoint not implemented

1 participant