Skip to content

fix: handle Pages Router middleware redirects#1439

Open
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/middleware-redirect-parity
Open

fix: handle Pages Router middleware redirects#1439
NathanDrake2406 wants to merge 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/middleware-redirect-parity

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Summary

  • Verified the upstream Next.js middleware redirect candidate before changing code. The direct deploy harness reproduced 4 legitimate failures in test/e2e/middleware-redirects/test/node-runtime.test.ts: internal client redirects and locale-prefixed API redirects, both with and without /fr.
  • Added Pages Router client support for the Next.js middleware data redirect protocol: when Pages middleware/proxy is present, client navigation probes /_next/data/<buildId>/*.json, consumes x-nextjs-redirect, and treats same-origin redirects as client redirects.
  • Exposed generated production page loaders to next/router, because production SSR HTML intentionally does not include source module URLs.
  • Propagated __vinext.hasMiddleware into Pages __NEXT_DATA__ in dev and prod so apps without middleware avoid the extra data probe.
  • Normalized locale-prefixed API redirect targets such as /nl/api/ok to /api/ok, matching the upstream client router behavior for locale-prefixed middleware redirects to API routes.

Root Cause

vinext already returned the right middleware redirect headers for _next/data requests, so the same-host and data-header candidate cases were already passing. The missing piece was the Pages Router client navigation path: it fetched the destination HTML directly and never performed the Next.js data-route redirect probe that upstream next/router uses before committing a client navigation.

That caused /old-home link clicks to miss the middleware redirect to /new-home, and caused locale-qualified /to?pathname=/api/ok navigations to remain stuck on the client route instead of falling through to the API response.

Upstream References

Validation

  • vp check
  • vp test run tests/shims.test.ts
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/middleware-redirects/test/node-runtime.test.ts
    • PASS, 19/19 upstream middleware redirect cases

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 22, 2026

Open in StackBlitz

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

commit: 8ee18dc

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: Pages Router middleware redirect parity

Solid work. The core approach is correct — probe /_next/data/<buildId>/*.json with x-nextjs-data: 1, consume x-nextjs-redirect, and handle same-origin vs external redirects. The __VINEXT_PAGE_LOADERS__ fallback for production builds where module URLs aren't in the HTML is a clean design. Tests cover the two primary paths well (internal client redirect, locale-prefixed API hard-nav).

A few things to look at below, plus one observation that doesn't fit an inline comment:

ISR parity gap

renderPagesIsrHtml in pages-page-data.ts (not in this diff) calls buildPagesNextDataScript without a vinext key. This means ISR-cached pages will produce __NEXT_DATA__ without hasMiddleware: true, so after ISR serves stale HTML, the client-side data probe will be skipped (hasVinextMiddleware() returns false). For apps using both ISR and middleware, middleware redirects could be missed on ISR-cached page navigations. This might be acceptable for now (ISR + middleware is an unusual combo), but worth noting — or threading vinext: { hasMiddleware } through the ISR render options to close the gap.

Comment thread packages/vinext/src/shims/router.ts Outdated
},
signal,
});
return res.headers.get("x-nextjs-redirect");
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.

Resilience concern: data probe errors bubble up as navigation failures.

If the fetch throws (network timeout, DNS failure), the error propagates to navigateClient's catch block which only handles AbortError and re-throws everything else. This means a transient network failure on the probe (which is purely an optimization) prevents the regular page fetch from even being attempted, and runNavigateClient will hard-navigate as recovery.

Upstream Next.js's client router treats data fetch failures more gracefully — it falls through to a full page load. Consider catching non-abort errors inside resolveMiddlewareDataRedirect itself and returning null, so the navigation falls through to the normal SSR HTML fetch:

async function resolveMiddlewareDataRedirect(
  browserUrl: string,
  signal: AbortSignal,
): Promise<string | null> {
  const dataUrl = getPagesDataFetchUrl(browserUrl);
  if (!dataUrl) return null;

  try {
    const res = await fetch(dataUrl, {
      headers: { Accept: "application/json", "x-nextjs-data": "1" },
      signal,
    });
    return res.headers.get("x-nextjs-redirect");
  } catch (err: unknown) {
    if (err instanceof DOMException && err.name === "AbortError") throw err;
    return null; // probe failed — fall through to normal navigation
  }
}

This would also simplify the catch block in navigateClient at line 762-767.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in fbb0332. resolveMiddlewareDataRedirect() now catches non-abort probe failures and returns null, so navigation falls through to the normal HTML fetch. Abort still propagates back to navigateClient() and is converted to NavigationCancelledError, preserving superseded-navigation behavior. Added regression coverage in tests/shims.test.ts for a failing data probe followed by successful page navigation.

Comment thread packages/vinext/src/shims/router.ts Outdated
const firstSegment = pathname.split("/")[1];
if (!firstSegment || !locales.includes(firstSegment)) return appPath;

const withoutLocale = pathname.slice(firstSegment.length + 1) || "/";
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 pathname.slice(firstSegment.length + 1) calculation depends on pathname starting with / (so the locale segment starts at index 1). This is safe because pathname comes from new URL(...).pathname which always has a leading /. But it's a subtle invariant — a brief comment would help future readers:

Suggested change
const withoutLocale = pathname.slice(firstSegment.length + 1) || "/";
const withoutLocale = pathname.slice(firstSegment.length + 1) || "/"; // +1 for leading `/`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied in fbb0332. Added the invariant comment on the slice offset.

Comment thread packages/vinext/src/shims/router.ts Outdated
scheduleHardNavigationAndThrow(url, "Navigation failed: invalid page module path");
if (pendingRedirectHistoryUrl) {
window.history.replaceState(window.history.state ?? {}, "", pendingRedirectHistoryUrl);
_lastPathnameAndSearch = window.location.pathname + window.location.search;
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.

The replaceState happens before the page module import (loadPagesPageModule). If the module import fails (network error loading the chunk), the URL bar shows the redirect target but content is stale until hard-nav recovery kicks in. This matches upstream Next.js behavior (they also commit the URL early), so it's fine — just noting the transient URL/content mismatch window exists.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified. This matches the intended upstream-compatible behavior, and no code change is needed for this thread. The hard-navigation recovery path still handles module load failures after the early URL commit.

${loaderEntries.join(",\n")}
};
window.__VINEXT_PAGE_LOADERS__ = pageLoaders;
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.

Good addition. The loaders are keyed by route pattern and loadPagesPageModule looks them up via nextData.page — these should be consistent since both come from the route matcher's routePattern. Worth a quick sanity check that dynamic routes (e.g. /blog/[slug]) use the same key in both the loader map and __NEXT_DATA__.page.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified. Dynamic route keys use the same route-pattern source on both sides: pages-client-entry.ts stores loaders under pagesPatternToNextFormat(r.pattern), and pages-server-entry.ts serializes __NEXT_DATA__.page from patternToNextFormat(route.pattern). Those are the same formatter over the matched route pattern, so /blog/[slug] style routes line up. No code change needed.

if (redirectLocation) {
const redirectedUrl = resolveLocalRedirectUrl(redirectLocation);
if (!redirectedUrl) {
scheduleHardNavigationAndThrow(redirectLocation, "Navigation redirected externally");
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.

When resolveLocalRedirectUrl returns null (external redirect), scheduleHardNavigationAndThrow is called with the raw redirectLocation from x-nextjs-redirect. Protocol-relative URLs like //evil.com/path would be correctly rejected by resolveLocalRedirectUrl (the startsWith("//") check), then passed to window.location.href as a hard-nav target. This matches upstream behavior since the middleware author controls the redirect target and NextResponse.redirect() validates the URL. Just flagging for awareness — if you ever want defense-in-depth here, normalize to an absolute URL before the hard-nav.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified. This is intentional parity with the middleware-controlled redirect target: protocol-relative values are rejected as local redirects, then treated as external hard-navigation targets. NextResponse.redirect() already validates middleware-authored redirect URLs. No code change needed here.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 22, 2026

Review posted on PR #1439. Here's the summary:

Overall: The approach is correct and well-structured. The core data probe mechanism (/_next/data/<buildId>/*.json with x-nextjs-redirect header consumption) matches upstream Next.js behavior. The __VINEXT_PAGE_LOADERS__ global for production builds is a clean solution.

Key feedback points:

  1. Resilience concern (router.ts:662) — resolveMiddlewareDataRedirect doesn't catch non-abort fetch errors. A transient network failure on the probe (which is purely an optimization) kills the entire navigation instead of falling through to the normal SSR HTML fetch. Suggested moving error handling into the helper itself.

  2. ISR parity gap (top-level comment) — renderPagesIsrHtml in pages-page-data.ts doesn't pass vinext: { hasMiddleware } to buildPagesNextDataScript, so ISR-cached pages will lack the middleware flag and skip the data probe on client navigation. Flagged for awareness.

  3. Minor nits — subtle invariant comment on stripLocalePrefixForApiRedirect, noting the early replaceState commit matches upstream, and a protocol-relative URL defense-in-depth observation.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/middleware-redirect-parity branch from 3453cb5 to fbb0332 Compare May 22, 2026 12:21
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Addressed the review in fbb0332.

  • Fixed the data-probe resilience issue: non-abort failures now return null and fall through to normal HTML navigation; aborts still cancel the navigation. Added a focused regression in tests/shims.test.ts.
  • Fixed the ISR parity gap: ResolvePagesPageDataOptions and renderPagesIsrHtml() now carry vinext metadata, and the generated Pages server entry passes { hasMiddleware } through ISR regeneration. Added assertions in tests/pages-page-data.test.ts for both direct ISR HTML rewrite and stale regeneration.
  • Applied the slice-offset invariant comment.
  • Verified the dynamic loader-key thread and protocol-relative external redirect thread; both match current architecture/upstream behavior and need no code change.

Validation rerun:

  • vp check
  • vp test run tests/pages-page-data.test.ts
  • vp test run tests/entry-templates.test.ts tests/shims.test.ts
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/middleware-redirects/test/node-runtime.test.ts (19/19 passed)

@james-elicx
Copy link
Copy Markdown
Member

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Review: Pages Router middleware redirect parity

The previous review's feedback (data probe resilience, ISR parity gap, slice-offset comment) has all been addressed. The implementation is correct and matches upstream Next.js behavior. Approving.

What this does right:

  1. The hasMiddleware gate via __NEXT_DATA__.__vinext is clean — apps without middleware pay zero cost (no probe fetch). The flag is threaded through all four render paths: dev normal, dev ISR regen, prod resolvePagesPageData, and prod renderPagesPageResponse.

  2. resolveMiddlewareDataRedirect correctly swallows non-abort errors and returns null, so probe failures fall through to normal HTML navigation. Abort errors still propagate as NavigationCancelledError for superseded-navigation handling.

  3. loadPagesPageModule is a nice extraction — the three-tier fallback (pageModuleUrl → HTML regex → __VINEXT_PAGE_LOADERS__) consolidates page-loading logic that was previously inline in navigateClient.

  4. The locale-prefix stripping for API redirects (stripLocalePrefixForApiRedirect) correctly scopes to /api paths only, matching the upstream client router's special-casing.

  5. Test coverage hits the three primary paths: internal client redirect, probe failure fallthrough, and locale-prefixed API hard-nav. The ISR assertions verify hasMiddleware round-trips through cache storage.

Two minor observations (not blocking):

Comment thread packages/vinext/src/shims/router.ts Outdated
if (parsed.origin !== window.location.origin) return null;

const appPathname = stripBasePath(parsed.pathname, __basePath);
const normalizedPathname = appPathname === "/" ? "/index" : appPathname.replace(/\/$/, "");
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 /index special-casing and trailing-slash strip produce the correct data URL for Next.js's convention, but there's no corresponding handling for routes that end in /index literally (e.g., a page at pages/index/index.tsx). This is an edge case that Next.js itself barely supports, so not actionable — just noting it.

Comment thread packages/vinext/src/shims/router.ts Outdated
Comment on lines +832 to +848
if (!isValidModulePath(pageModuleUrl)) {
console.error("[vinext] Blocked import of invalid page module path:", pageModuleUrl);
scheduleHardNavigationAndThrow(url, "Navigation failed: invalid page module path");
if (pendingRedirectHistoryUrl) {
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.

The replaceState commit happens before loadPagesPageModule, which is the same ordering as upstream (URL commits before async module load). If you ever add retry/fallback logic for module load failures, keep this ordering in mind — the URL bar will already show the redirect target.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 22, 2026

Review posted and approved. Here's a summary:

Approved — all previous review feedback has been addressed. The implementation correctly adds Pages Router client-side middleware redirect support by:

  1. Probing /_next/data/<buildId>/*.json with x-nextjs-data: 1 header to detect middleware redirects before committing to HTML fetch
  2. Gating the probe behind hasMiddleware so apps without middleware pay zero cost
  3. Threading the flag through all four server render paths (dev, dev ISR regen, prod page data, prod page response)
  4. Extracting page-module loading into loadPagesPageModule with a clean three-tier fallback (vinext URL → HTML regex → __VINEXT_PAGE_LOADERS__)
  5. Handling probe failures gracefully (non-abort errors return null, falling through to normal navigation)

Two non-blocking observations left as inline comments about the /index data URL edge case and the replaceState ordering relative to async module loads.

github run

@NathanDrake2406 NathanDrake2406 force-pushed the nathan/middleware-redirect-parity branch from fbb0332 to f07f69a Compare May 22, 2026 14:01
Copilot AI review requested due to automatic review settings May 22, 2026 17:14
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/middleware-redirect-parity branch from f07f69a to 42b65b0 Compare May 22, 2026 17:14
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Resolved the conflict by rebasing nathan/middleware-redirect-parity onto current main (62454ef4).

Follow-up from the review/conflict resolution:

  • Removed the new production component type assertions and replaced them with runtime guards for page/_app modules.
  • Reused the shared Pages Router data URL helper for the middleware data probe instead of rebuilding /_next/data/<buildId>/*.json URLs locally.
  • Checked adjacent redirect/basePath/data-url logic for duplicate canonicalization; the only actionable duplication was the data URL construction above.

Verification:

  • vp test run tests/shims.test.ts tests/pages-data-url.test.ts tests/pages-page-data.test.ts tests/entry-templates.test.ts
  • vp check
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/middleware-redirects/test/node-runtime.test.ts (19/19 passed)

GitHub now reports mergeable: MERGEABLE; mergeStateStatus is BLOCKED, so the remaining gate appears to be branch protection/check state rather than a mechanical conflict.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates vinext’s Pages Router client navigation to match Next.js middleware redirect semantics by probing /_next/data/<buildId>/*.json for x-nextjs-redirect, enabling client-side handling of same-origin redirects, and carrying a hasMiddleware flag through __NEXT_DATA__ to avoid unnecessary probes when middleware isn’t configured.

Changes:

  • Add middleware data-probe redirect handling to the Pages Router client (shims/router.ts), including same-origin vs external redirect behavior and locale-prefixed API redirect normalization.
  • Expose production Pages Router page loaders on window and propagate __vinext.hasMiddleware into __NEXT_DATA__ in dev + prod server paths.
  • Extend/adjust tests to cover middleware redirect scenarios and ensure __vinext metadata is present in rendered HTML/ISR cache.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/shims.test.ts Adds unit coverage for middleware redirect probe behavior (internal redirects, basePath, probe failure fallback, API hard-nav).
tests/pages-page-data.test.ts Verifies __vinext.hasMiddleware is included in HTML and ISR-cached payloads.
packages/vinext/src/shims/router.ts Implements Pages Router middleware redirect probe + redirect URL normalization; adds module/component type guards and loader fallback.
packages/vinext/src/server/pages-page-response.ts Threads optional vinext metadata into __NEXT_DATA__ script generation.
packages/vinext/src/server/pages-page-data.ts Passes vinext metadata through ISR/Pages page data rendering pipeline.
packages/vinext/src/server/dev-server.ts Adds hasMiddleware parameter and injects it into __NEXT_DATA__.__vinext in dev responses.
packages/vinext/src/index.ts Passes middleware presence into the dev SSR handler.
packages/vinext/src/global.d.ts Updates window global typings for Pages Router runtime globals (but currently introduces a duplicate conflicting declaration).
packages/vinext/src/entries/pages-server-entry.ts Embeds middleware presence into server-rendered __NEXT_DATA__.__vinext.
packages/vinext/src/entries/pages-client-entry.ts Exposes __VINEXT_PAGE_LOADERS__ on window for production navigation.
packages/vinext/src/client/vinext-next-data.ts Extends VinextNextData.__vinext with hasMiddleware.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/vinext/src/entries/pages-client-entry.ts Outdated
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/middleware-redirect-parity branch from 42b65b0 to 2437c32 Compare May 22, 2026 17:30
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Addressed the new review in 2437c32c. The duplicate loader assignment comment was valid.

Changes:

  • Removed the second window.__VINEXT_PAGE_LOADERS__ = pageLoaders assignment from pages-client-entry.ts.
  • Also fixed the related duplicate Window.__VINEXT_PAGE_LOADERS__ declaration in global.d.ts that Copilot called out in the review summary, keeping the broader module-loader shape used by the router.

Verification:

  • vp test run tests/shims.test.ts tests/entry-templates.test.ts -t "pages router data navigation|middleware data redirects|middleware internal redirects|does not double-prefix basePath"
  • vp test run tests/entry-templates.test.ts
  • vp check

The amend hook also reran entry-template tests, checks, knip, and rebuilt packages/vinext/dist.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 10 changed files in this pull request and generated 1 comment.

Comments suppressed due to low confidence (2)

packages/vinext/src/shims/router.ts:806

  • isPageComponent validates that the default export is a React component (function/forwardRef/memo). When this check fails the thrown message says "page module has no default export", which is misleading if a default export exists but is not a component. Consider updating the error text to reflect an invalid default export (or include the detected type) to make debugging easier.

This issue also appears on line 1001 of the same file.

  const PageComponent = pageModule.default;
  if (!isPageComponent(PageComponent)) {
    scheduleHardNavigationAndThrow(
      url,
      "Data navigation failed: page module has no default export",
    );

packages/vinext/src/shims/router.ts:1006

  • Same issue as the data-navigation path: isPageComponent can fail even when a default export exists (e.g. default-exporting a non-component). The error message currently claims there is "no default export"; please adjust it to describe an invalid/non-component default export so the hard-navigation fallback is diagnosable.
  const PageComponent = pageModule.default;
  if (!isPageComponent(PageComponent)) {
    scheduleHardNavigationAndThrow(
      browserUrl,
      "Navigation failed: page module has no default export",
    );

Comment thread packages/vinext/src/shims/router.ts Outdated
Comment on lines +605 to +618
function getWindowOrigin(): string {
const { origin, href } = window.location;
if (origin) return origin;
return new URL(href).origin;
}

function resolveSameOriginRedirectedUrl(responseUrl: string): string | null {
const appPath = toSameOriginAppPath(responseUrl, __basePath);
if (appPath === null) return null;
return normalizePathTrailingSlash(
toBrowserNavigationHref(appPath, window.location.href, __basePath),
__trailingSlash,
);
}
@NathanDrake2406 NathanDrake2406 force-pushed the nathan/middleware-redirect-parity branch from 2437c32 to 8ee18dc Compare May 23, 2026 06:58
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Addressed the new review in 8ee18dc4.

Assessment:

  • The same-origin origin-source comment was valid. getMiddlewarePagesDataFetchUrl() had a location.href fallback, while toSameOriginAppPath() still depended directly on window.location.origin.
  • The low-confidence error-message comments were also valid: the guard can fail when a default export exists but is not a valid component, so the old "no default export" message was misleading.

Changes:

  • Moved the origin fallback into shared url-utils.ts via getWindowOrigin(), and made toSameOriginPath() use it for absolute and protocol-relative URLs. The router now imports the shared helper instead of owning a local copy.
  • Added a regression in tests/link.test.ts for same-origin absolute and protocol-relative URLs when location.origin is unavailable.
  • Updated the hard-navigation messages for invalid page component defaults.

Verification:

  • vp test run tests/link.test.ts tests/shims.test.ts -t "toSameOriginAppPath|middleware data redirects|middleware internal redirects|does not double-prefix basePath|hard-navigates to the final middleware redirect URL|pages router data navigation"
  • vp check

The amend hook also reran checks, knip, and rebuilt packages/vinext/dist.

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.

3 participants