fix(pages): answer middleware data misses with a 404 by james-elicx · Pull Request #3912 · cloudflare/vinext · GitHub
Skip to content

fix(pages): answer middleware data misses with a 404 - #3912

Open
james-elicx wants to merge 4 commits into
mainfrom
fix/pages-data-miss-status
Open

james-elicx wants to merge 4 commits into
mainfrom
fix/pages-data-miss-status

Conversation

@james-elicx

@james-elicx james-elicx commented Oct 12, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #3859 and #3872.

When an app has middleware, vinext answered every _next/data request that matched no page with 200 {}. Real Next.js 16.2.7 answers these with a 404, which makes the Pages client hard-navigate:

App Next.js data miss vinext before vinext now
Pages-only + middleware 404 JSON {}, x-nextjs-matched-path: /en/nope 200 {} + matched path 404 JSON {} + matched path
Hybrid + middleware 404 (App not-found route), no matched path 200 {} + decoded matched path 404 JSON {}, no matched path
Either, no middleware 404 HTML page 404 JSON {} unchanged

The table comes from next build --webpack + next start probes of a hybrid and a Pages-only app (i18n en/fr, with and without middleware), using x-nextjs-data: 1 requests.

The source of this behaviour is Next's router-server.ts. A miss goes through invokeRender('/404'). When the app has middleware matchers, that returns res.statusCode = 404 with {} and the matched path. A hybrid app goes to /_not-found instead, so it never takes that branch.

The 200 {} that vinext had ported (#2239) comes from base-server.ts. It only fires when the router matched a page file but the page renderer then couldn't. None of Next's own tests expect 200 for a data miss. middleware-rewrites checks that the client hard-navigates on a data 404. vinext's client already hard-navigates on these: a miss has no page loader, so it fetches the HTML.

Changes

  • Pages request pipeline (prod, Worker, hybrid dev Pages): a middleware data miss returns 404 instead of 200.
  • Pages-only dev server's route-miss exit: returns the same.
  • Hybrid handler: a true miss returns 404 without x-nextjs-matched-path. A data request that middleware rewrites onto an App route keeps its 200 placeholder: Next serves the App page there, and the placeholder's x-nextjs-rewrite drives the client's hard navigation.
  • Cache policy: these data-miss 404s now carry Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, which Next's router-server.ts sets before taking the 404 path. A matching next.config Cache-Control rule can't replace it.

Tests

  • Updated: the existing unit and integration expectations (pages-request-pipeline, app-rsc-handler, pages-router, features), which asserted the old 200. They now also assert the never-cache policy.
  • New e2e: pages-router-prod (pages-basic) and app-router-encoded-basepath-i18n (app-basic, hybrid). Both fail with 200 without this change.

Known limitations

Next.js router-server.ts answers a `_next/data` request that matches no
page with a 404. In a Pages-only app with middleware it is a JSON `{}`
naming the requested path in `x-nextjs-matched-path`; in a hybrid app it
goes to the App not-found route, without the matched path. vinext answered
every such miss with `200 {}` whenever the app had middleware.

Pages misses (prod, Worker and dev) now return the JSON 404 with the
matched path, and hybrid misses return a 404 without it. A hybrid data
request that middleware rewrites onto an App route keeps its 200
placeholder, which carries `x-nextjs-rewrite` for the client's hard
navigation.
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head 9ccb51e470c7ee33aab4707511adc62739621124 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@pkg-pr-new

pkg-pr-new Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

npm i https://pkg.pr.new/@vinext/cloudflare@3912

create-vinext-app

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

@vinext/types

npm i https://pkg.pr.new/@vinext/types@3912

vinext

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

@cloudflare/workers-response-store

npm i https://pkg.pr.new/@cloudflare/workers-response-store@3912

commit: b109a06

// An App match is served (the Pages client hard-navigates to it). A miss
// goes to the App not-found route in Next.js router-server.ts, so unlike
// a Pages-only app it gets a 404 without a matched path.
if (!match) return new Response("{}", { status: 404, headers });

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 new hybrid miss bypasses withUnmatchedRouteCacheControl, so after finalizeAppRscResponse a matching headers() rule (or the origin-managed adapter default) can leave this 404 with a cacheable policy or no policy at all. Next.js sets Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate in router-server.ts before taking this data-miss shortcut. The same omission exists in the new 404 exits in pages-request-pipeline.ts and dev-server.ts; please apply/reassert the unmatched-route cache policy in all three paths and cover it in the updated tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 46ed950: the hybrid misses (with and without middleware) go through withUnmatchedRouteCacheControl, the Pages pipeline exit applies NEVER_CACHE_CONTROL on the response headers (these take precedence over staged config headers in mergeHeaders), and the dev data-miss exit sends it too. The unit tests cover a matching next.config Cache-Control rule; the integration and e2e tests assert the header.

@ask-bonk

ask-bonk Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted one actionable inline warning. No branch modifications were made.

github run

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared b109a06 against base 43a82db using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 7 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 144.8 KB 144.8 KB ⚫ +0.0%
Client entry size (gzip) vinext 132.6 KB 132.6 KB ⚫ +0.0%
Dev server cold start vinext 2.89 s 2.86 s ⚫ -1.0%
Dev server cold start (large Pages Router app) vinext 24.54 s 24.26 s ⚫ -1.2%
Production build time vinext 3.14 s 3.15 s ⚫ +0.4%
RSC entry closure size (gzip) vinext 133.9 KB 133.9 KB ⚫ +0.0%
Server bundle size (gzip) vinext 237.5 KB 237.6 KB ⚫ +0.0%

View detailed results and traces

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

@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
response-store-demo preview production
workers-cache preview production
kv preview production
static-assets-cache preview production
static-assets-pages preview production
static-assets-pages-i18n preview production
static-export preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head 46ed950600dfa735b148492b367bca50fefec10a without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

const notFoundResponse = new Response("{}", { status: 200, headers });
// Response headers win over the staged config headers in mergeHeaders,
// so a next.config Cache-Control rule can't replace this policy.
applyCdnResponseHeaders(headers, { cacheControl: NEVER_CACHE_CONTROL });

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.

Apply the adapter policy after the final header merge. applyCdnResponseHeaders removes adapter-owned headers only from this temporary headers object, but mergeHeaders then restores any staged CDN-Cache-Control or Cloudflare-CDN-Cache-Control value from matchedPathHeaders. With CloudflareCdnCacheAdapter, those headers take precedence over the private Cache-Control, so a matching config rule or continuing middleware can still make this 404 edge-cacheable. Merge first, then call applyCdnResponseHeaders on the merged response headers (and add an adapter-backed regression test).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in cc2e7e5: the pipeline now merges the staged headers first and calls applyCdnResponseHeaders on the merged response's headers. Added a CloudflareCdnCacheAdapter-backed test where a config rule stages Cloudflare-CDN-Cache-Control and middleware stages CDN-Cache-Control; both are dropped, and it fails on the previous head.

@ask-bonk

ask-bonk Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Since last review: 1 resolved, 0 still open, 1 new.

Posted one actionable inline warning. No branch modifications were made.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head cc2e7e515a612940c57c3049d00457a5a34cff95 without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

);
// Applied after the merge, so staged config or middleware headers can't
// restore a cacheable policy (including adapter-owned edge headers).
applyCdnResponseHeaders(notFoundResponse.headers, { cacheControl: NEVER_CACHE_CONTROL });

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 policy is still overwritten in the multi-stage Pages path. On an unmatched GET, pages-request-stage-entry.ts may dispatch the speculative 404 through the shared response stage; a middleware Cache-Control value is captured in sharedOuterPolicyHeaders, and after this return lines 672-674 call reconcileCdnResponseHeadersAfterOuterPolicy, whose lines 164-165 restore that value verbatim. Consequently middleware returning Cache-Control: public, s-maxage=3600 makes this data-miss 404 cacheable despite this reassertion. Apply the never-cache policy after that reconciliation (or prevent reconciliation for this synthetic response), and cover the request-stage caller rather than only runPagesRequest directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in b109a06: the pipeline result for this synthetic 404 now carries finalCachePolicy: true, and pages-request-stage-entry.ts skips reconcileCdnResponseHeadersAfterOuterPolicy for it. Added a request-stage test in pages-request-worker-stage.test.ts (middleware Cache-Control: public, s-maxage=3600, a shared speculative 404 dispatch) that fails on the previous head.

@ask-bonk

ask-bonk Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted one actionable inline warning. Targeted tests passed; no branch modifications.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

Please review exact head b109a064ad02c888e45258ecd1c96da13f9b94bf without modifying or pushing the branch. Report all actionable findings within your 8-minute time limit.

@ask-bonk

ask-bonk Bot commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

@james-elicx
james-elicx marked this pull request as ready for review October 12, 2026 17:03
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.

1 participant