feat(pages-router): consume _next/data JSON endpoint from the client#1412
Conversation
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.
commit: |
|
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.
|
/bigbonk review |
There was a problem hiding this comment.
Solid PR — the split into navigateClientData / navigateClientHtml with a shared orchestrator in navigateClient is clean, the round-trip tests between buildPagesDataPath and parseNextDataPathname are exactly the right thing to do, and the parseNextDataPathname bug fix for index/-prefixed paths is a good catch.
A few things I noticed:
query on __NEXT_DATA__ is missing search params after data-path navigation
In navigateClientData, the synthesised __NEXT_DATA__ sets:
query: { ...target.params },This only includes dynamic route params. After navigating to /about?foo=bar, window.__NEXT_DATA__.query will be {} instead of { foo: "bar" }.
The user-facing useRouter().query happens to still work because getPathnameAndQuery() independently reads window.location.search and merges. But any library or user code that reads window.__NEXT_DATA__.query directly (which is a public API surface in Next.js) will see stale/incomplete data. Next.js' __NEXT_DATA__.query always contains both dynamic params and search params merged.
target.search is already available on the resolved target — just needs to be parsed and spread into query alongside the params. Something like:
const searchQuery: Record<string, string | string[]> = {};
const searchParams = new URLSearchParams(target.search);
for (const [key, value] of searchParams) {
const existing = searchQuery[key];
if (existing !== undefined) {
searchQuery[key] = Array.isArray(existing) ? [...existing, value] : [existing, value];
} else {
searchQuery[key] = value;
}
}
// ...
query: { ...searchQuery, ...target.params },Dynamic params should win over search params (spread order), matching Next.js.
gSSP redirect returns HTTP 3xx, not soft-redirect — x-nextjs-redirect check is dead code
navigateClientData checks for x-nextjs-redirect header (the soft-redirect protocol), but the server side (pages-page-data.ts resolvePagesPageData) returns raw HTTP 307/308 with a Location header for gSSP/gSP redirects, even on /_next/data requests. The browser's fetch() transparently follows 3xx redirects, so:
- The
x-nextjs-redirectcheck at line ~673 never fires for server-originated redirects. - After the browser follows the 3xx, the response the client sees is whatever lives at the redirect target — likely an HTML page, not a JSON payload — which will fail the
res.json()parse and trigger a hard reload. So the end result is correct (user lands on the right page), but the error path is noisy/indirect.
Next.js handles this by returning 200 with x-nextjs-redirect header on data requests instead of a raw 3xx. This is documented as the "soft redirect" protocol. Not a blocker for this PR since the current behavior is functionally correct (fails to JSON, hard-reloads), but worth tracking as a follow-up to make the server emit soft-redirect responses for data requests specifically.
__VINEXT_PAGE_PATTERNS__ ordering
The comment in pages-client-entry.ts says the patterns list is exposed "so the router can sort it once (longest match first) without re-keying the loader map." But Object.keys(pageLoaders) returns insertion order from the generated code, which is whatever order pageRoutes.map(...) produces. I don't see any sorting happening — neither in the entry generator nor in matchPagesPattern (which explicitly says "Patterns are tried in patterns order — callers should pre-sort").
Is the route scanner already returning routes in priority order? If so the comment is misleading (it's not about the router sorting, it's about the scanner having already sorted). If not, there's a latent priority bug for apps with overlapping patterns like /posts/[id] and /posts/[...rest].
Minor nits
The navigateClientData and navigateClientHtml functions both have their own if (!root) guard + fallback. Since both are only called from navigateClient which already checks typeof window !== "undefined", the root check could be hoisted into navigateClient before the try block — would remove 8 lines of duplication. Not a big deal though.
Overall this is well-structured and the test coverage is thorough. The __NEXT_DATA__.query search-params gap is the main thing I'd want addressed before merge.
|
Review posted on PR #1412. Here's a summary of the key findings:
|
…ta-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.
|
Thanks for the review. Addressed in 9029611:
/bigbonk review |
There was a problem hiding this comment.
Verified the fixes in 9029611:
__NEXT_DATA__.query missing search params — Fix is correct. mergeRouteParamsIntoQuery(parseQueryString(target.search), target.params) properly parses the search string into a multi-value-aware record, then spreads route params on top so they win on collision. The utility functions in utils/query.ts are clean and well-factored. The regression test covers the key case: /blog/my-post?slug=spoofed&q=hello&tag=a&tag=b correctly produces { slug: "my-post", q: "hello", tag: ["a", "b"] } — route param slug beats the spoofed search param, single-value and multi-value search params both work.
__VINEXT_PAGE_PATTERNS__ ordering — Confirmed: pagesRouter() calls routes.sort(compareRoutes) at lines 85 and 234 of routing/pages-router.ts, so insertion order in the generated pageLoaders is already sorted by specificity. The updated comment now accurately describes this ("which pagesRouter() has already sorted by specificity … via compareRoutes").
x-nextjs-redirect dead code and !root duplication — fine as follow-ups / leave-as-is respectively.
LGTM.
|
Review posted. All three addressed items check out:
Approved the PR. |
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.
|
Added an E2E regression guard in 5529ccc ( /bigbonk review |
There was a problem hiding this comment.
Clean, well-structured PR. The split into navigateClientData / navigateClientHtml with a shared orchestrator in navigateClient reads clearly, the round-trip test suite between buildPagesDataPath and parseNextDataPathname is exactly the right thing for a wire-format contract, and the index/-prefix parsing fix in pages-data-route.ts:83 is a good catch.
E2E regression guard (5529ccc)
The new Playwright test at tests/e2e/cloudflare-pages-router/navigation.spec.ts:61 is the right shape: sniff the network for /_next/data/<buildId>/ssr.json requests and assert zero document-type requests to /ssr. This catches the exact failure mode where the loader manifest silently regresses and navigation falls back to HTML — something every other existing test would miss since they only assert on URL change + rendered content.
One thing worth noting: the test waits for __VINEXT_HYDRATED_AT (line 64) which is set at the end of hydrate() in the client entry (line 145 of pages-client-entry.ts), after __VINEXT_PAGE_LOADERS__ is already assigned (line 90). So the hydration gate is correct — by the time the test proceeds, the loader manifest is guaranteed to be populated.
Items verified from prior review rounds
-
Search params merge (9029611):
mergeRouteParamsIntoQuery(parseQueryString(target.search), target.params)atrouter.ts:766correctly parses multi-value search params viaaddQueryParamand spreads route params on top. The regression test for/blog/my-post?slug=spoofed&q=hello&tag=a&tag=bcovers the key collision case. -
Pattern ordering comment updated in
pages-client-entry.ts:87-89now correctly attributes the sort topagesRouter()'scompareRoutesat build time.
Minor observations (no action needed)
-
parseQueryStringaccepts a full URL, not just search string —parseQueryString(target.search)atrouter.ts:766passes a string like?foo=bar, and the function splits on?to extract the query portion. This works but is slightly redundant sincetarget.searchalready starts with?. Not a bug —URLSearchParams("foo=bar")andURLSearchParams("?foo=bar".split("?")[1])produce the same result — just noting the indirection. -
Prefetch duplication between
link.tsxandrouter.ts—link.tsx:259-281androuter.ts:1209-1231both independently resolve the data target and build prefetch links. The code is structurally identical. Not a blocker (thelink.tsxpath runs during component render and therouter.tspath runs fromRouter.prefetch()), but if the prefetch shape ever changes, both sites need updating.
LGTM. The test coverage is thorough across unit and E2E, the failure modes all funnel to hard-reload correctly, and the addressed review items check out.
|
Review posted and PR approved. Here's a summary of the review: Approved — the E2E regression guard in 5529ccc is the right shape. It sniffs the network for Verified the fixes from prior review rounds (search params merge, pattern ordering comment). Noted two minor observations — |
…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.
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.
…xample
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.
…orkers example" This reverts commit 719575b.
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.
# Conflicts: # packages/vinext/src/entries/pages-server-entry.ts
|
Moved the Merged latest main and resolved the conflict in All 37 CI checks green including the new E2E regression guard. /bigbonk review |
There was a problem hiding this comment.
Thorough review of the full PR after the latest rebase and the move of _next/data normalization into the generated server entry.
Architecture
The split into navigateClientData / navigateClientHtml with a shared orchestrator in navigateClient is well-structured. The resolvePagesDataNavigationTarget function cleanly encapsulates the "can we use the fast path?" decision, and returning null to fall through to HTML is the right escape hatch. The __normalizePagesDataRequest helper in the generated server entry correctly centralizes data URL normalization so user-written worker entries stay thin — verified that all three server contexts (prod-server, dev-server, Cloudflare worker/deploy.ts) handle _next/data requests correctly with no parity gap.
The round-trip test contract between buildPagesDataPath (client) and parseNextDataPathname (server) is exactly the right thing to do for a wire format, and it caught the index/-prefix bug in pages-data-route.ts:83.
The E2E regression guard (navigation.spec.ts) is well-designed — the sentinel + network sniff approach catches the exact failure mode where the loader manifest silently regresses and the client falls through to HTML without any visible breakage.
Issues found
1. gSSP/gSP redirect on data requests returns raw 3xx instead of JSON body (follow-up, not blocking)
resolvePagesPageData returns a raw HTTP 307/308 with Location header for gSSP/gSP redirects, even on _next/data requests. The browser's fetch() transparently follows the 3xx per the Fetch spec, so the client never sees the redirect — it gets whatever the redirect destination serves (likely HTML), the res.json() parse fails, and the client hard-reloads. The end result is correct but indirect and noisy.
Next.js handles this differently: on data requests, it returns HTTP 200 with { __N_REDIRECT: "/destination", __N_REDIRECT_STATUS: 307 } in the JSON body. The client router interprets those fields and performs a client-side navigation to the redirect target, avoiding a full page reload.
The x-nextjs-redirect check at router.ts:684 is only reachable for middleware redirects (which do emit the header). For gSSP/gSP redirects, it's dead code on the current server implementation.
Already called out as out-of-scope in the PR description and prior reviews. Noting for completeness — the server-side fix (emit 200 + JSON body for data-request redirects) is the real fix and belongs in a separate PR.
2. Locale transitions silently skip the data path (latent, not blocking)
resolvePagesDataNavigationTarget calls matchPagesPattern(pagePath, patterns) where pagePath still includes the locale prefix (e.g., /fr/about). Route patterns in __VINEXT_PAGE_PATTERNS__ are locale-unaware (/about), so the match always fails for non-default-locale URLs. resolvePagesDataNavigationTarget returns null and the code correctly falls through to the HTML path.
This means locale transitions (and all non-default-locale navigations) never benefit from the JSON data path — they always take the slower HTML round trip. Not a correctness bug (the fallback works), but a performance gap worth knowing about.
If this is ever fixed (stripping locale prefix before pattern matching), navigateClientData also needs to be updated: it currently inherits locale fields from ...prev (the previous __NEXT_DATA__), which would be stale after a locale transition. The data JSON response only contains { pageProps } with no locale metadata.
3. Prefetch duplication between link.tsx and router.ts (nit)
The prefetch logic in link.tsx:309-360 and router.ts:1234-1267 is structurally identical — both resolve the data target, inject <link rel="prefetch" as="fetch">, and invoke the loader thunk. If the prefetch shape changes (e.g., adding Accept header hints, or switching from <link> to fetch() with keepalive), both sites need updating. Not blocking, but worth extracting into a shared helper at some point.
Verified from prior rounds
- Search params merge (9029611):
mergeRouteParamsIntoQuery(parseQueryString(target.search), target.params)correctly merges search params with route params winning on collision. Regression test covers the key case. - Pattern ordering comment updated to reference build-time sort via
compareRoutes. - Server parity:
__normalizePagesDataRequestin the generated entry handles the Cloudflare Workers path (deploy.ts) correctly. prod-server.ts does its own normalization before calling the entry. dev-server.ts (index.ts) does its own normalization before the SSR handler. No gaps. - E2E guard (5529ccc): hydration gate via
__VINEXT_HYDRATED_ATis correctly ordered after__VINEXT_PAGE_LOADERS__assignment. Window sentinel approach is more robust than resource-type sniffing.
LGTM — the items above are tracked follow-ups or latent issues, not blockers. The core navigation/prefetch/normalization logic is correct and well-tested.
|
Review posted and PR approved. Here's a summary of the key findings: Architecture: Clean and correct. The Three items noted (none blocking):
|
…nd 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.
|
Addressed two of the three review nits in 0a2760a:
All 4738 unit tests pass locally including the new locale regression tests. /bigbonk review |
There was a problem hiding this comment.
Reviewed the refactor in 0a2760a. All three items check out.
Prefetch deduplication
Clean extraction. resolvePagesDataNavigationTarget + prefetchPagesData in shims/internal/pages-data-target.ts is the right factoring — the module lives under shims/internal/ so neither link.tsx nor router.ts pulls in the other at init time (no circular imports, no SSR side effects from the Router singleton). Both the Link prefetch path (line 324 of link.tsx) and the Router prefetch/navigation path (lines 930 and 1193 of router.ts) now go through the same resolver, so there is exactly one decision site for "can we use the JSON fast path?".
The link.tsx diff is a clean 40-line deletion with 6 lines replacing it — the inline try/catch + URL parse + stripBasePath + matchPagesPattern + buildPagesDataHref + createElement sequence is gone, replaced by two function calls. Good.
Locale transitions
The locale-stripping logic at pages-data-target.ts:84-87 is correct:
const locale = getLocalePathPrefix(pagePath, window.__VINEXT_LOCALES__);
const pathForMatch = locale ? pagePath.slice(locale.length + 1) || "/" : pagePath;getLocalePathPrefixreturns the matched locale string (e.g."fr") orundefined. The+ 1accounts for the leading/in the/<locale>segment. The|| "/"handles the edge case where the URL is just/<locale>(e.g./fr) which should resolve to the root page.pagePath(with locale prefix intact) is passed through tobuildPagesDataHref, so the data URL keeps the prefix (e.g./_next/data/<id>/fr/about.json), which is what the server expects for locale-specific gSSP data. Correct.
The locale refresh in navigateClientData (router.ts:729-732) handles both directions:
const hasI18n = (window.__VINEXT_LOCALES__?.length ?? 0) > 0;
const nextLocale = hasI18n
? (target.locale ?? window.__VINEXT_DEFAULT_LOCALE__)
: (prev as VinextNextData | undefined)?.locale;- Locale transition (
/en/about→/fr/about):target.localeis"fr", sonextLocaleis"fr". The spread...(nextLocale !== undefined ? { locale: nextLocale } : {})overridesprev.locale. - Back to default (
/fr/about→/about):target.localeisundefined(no prefix on an unprefixed URL), sonextLocalefalls back towindow.__VINEXT_DEFAULT_LOCALE__(e.g."en"). Correct — without this, locale would stay stale at"fr". - No i18n configured:
hasI18nisfalse, sonextLocaleinherits fromprev.localeunchanged. Also correct — no locale to refresh.
Then applyVinextLocaleGlobals(window, nextData) writes the new locale to window.__VINEXT_LOCALE__, so useRouter().locale (which reads from that global via the Router.locale getter at line 1497) reflects the transition immediately.
Tests
Both regression tests are well-structured:
-
Locale-prefixed nav (
strips the locale prefix): navigates to/fr/about, asserts the fetch URL is/_next/data/test-build/fr/about.json(locale prefix preserved in data URL), the matched page is/about(locale stripped for pattern match), and both__NEXT_DATA__.localeandwindow.__VINEXT_LOCALE__flip to"fr". -
Default-locale restore (
restores the default locale): starts on/fr, navigates to/aboutwith{ locale: "en" }, asserts the data URL has no locale prefix (/_next/data/test-build/about.json), and locale globals reset to"en".
Both tests properly clean up globalThis.window and globalThis.fetch in finally blocks, and call vi.resetModules() to avoid cross-test pollution from the router module's singleton state.
LGTM.
|
Review posted and PR approved. Here's a summary of what I verified: Prefetch deduplication -- Clean extraction of Locale transitions -- The locale-stripping logic correctly peels off the Tests -- Both regression tests are well-structured with proper cleanup and cover the key scenarios (locale-prefixed navigation and default-locale restore). All 11 |
The server-side
/_next/data/<buildId>/<page>.jsonendpoint 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.Closes the follow-up half of #1330.
What changed
Client navigation (
shims/router.ts).navigateClientis split into two paths:navigateClientData(new): whenwindow.__VINEXT_PAGE_LOADERS__has a registered code-split loader for the target route AND we have a buildId, fetch/_next/data/<buildId>/<page>.jsonwithAccept: application/json+x-nextjs-data: 1. On 200 parse the envelope, look up the page module via the in-memory loader map, 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 inpageLoaders. The generated client entry now exposes that map onwindow.__VINEXT_PAGE_LOADERS__(route pattern → loader thunk), pluswindow.__VINEXT_PAGE_PATTERNS__(ordered list) andwindow.__VINEXT_APP_LOADER__(the_appthunk, when present). This replaces Next.js'_buildManifest.jsindirection — 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 mirroringgetAssetPathFromRoute+getDataHreffrom Next.js://_next/data/<id>/index.json/about/_next/data/<id>/about.json/index/_next/data/<id>/index/index.json/blog/foo/_next/data/<id>/blog/foo.jsonRound-trip tests verify the server's
parseNextDataPathnameagrees with the client'sbuildPagesDataPathfor every shape — and surfaced a latent server bug:/_next/data/<id>/index/foo.jsonwas parsing back to/index/fooinstead of/foo. Fixed inpages-data-route.tsso the round-trip is now lossless.Prefetch (
shims/link.tsx+shims/router.ts).<Link>prefetch andRouter.prefetch()now prefer the data path:<link rel=\"prefetch\" as=\"fetch\" crossorigin href={dataUrl}>for the JSON, matching the actual navigation's request shape.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.This also fixes a long-standing prefetch bug as a side effect: 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.Ports from Next.js
getDataHrefin.nextjs-ref/packages/next/src/client/page-loader.ts:154fetchNextDatain.nextjs-ref/packages/next/src/shared/lib/router/router.ts:498getAssetPathFromRoutein.nextjs-ref/packages/next/src/shared/lib/router/utils/get-asset-path-from-route.tsTests
tests/pages-data-url.test.ts(new): 27 tests covering URL construction and the client/server round trip.tests/shims.test.ts(+8 new tests): JSON-path navigation success, 404 hard-reload (build-skew),x-nextjs-redirectsoft-redirect handling, HTML fallback when no loader matches, dynamic param extraction, malformed JSON hard-reload, prefetch JSON path, prefetch fallback.Verified locally: 1393 vitest tests pass across shims, pages-router, link/navigation, prefetch, data-route, data-url, entry-templates, and routing test files.
Out of scope (potential follow-ups)
Router.replaceinstead of a hard reload. Today thex-nextjs-redirectheader hard-loads; a future PR can wire it throughRouter.replaceto keep the redirect a client-side navigation.