Skip to content

fix(pages): append edge API route headers after middleware's - #4079

Open
james-elicx wants to merge 1 commit into
fix/pages-api-thrown-handler-headersfrom
fix/pages-edge-api-header-append
Open

james-elicx wants to merge 1 commit into
fix/pages-api-thrown-handler-headersfrom
fix/pages-edge-api-header-append

Conversation

@james-elicx

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

Copy link
Copy Markdown
Member

Summary

Next.js runEdgeFunction() calls res.appendHeader() for every header of an edge API route's Response, and res already holds the next.config and middleware headers. So when middleware and the handler set the same header, Next sends middleware's value and then the handler's, in dev and prod alike. NodeNextResponse.appendHeader() skips a value the header already carries, so a value that both sides set, or a repeated cookie, is sent only once. vinext's result depended on the path:

  • Hybrid apps: middleware won.
  • Pages-only production: the handler won.
  • Dev: the handler won, and middleware's cookie was replaced as well.

Middleware sets cookie mw=1, WWW-Authenticate / Proxy-Authenticate: Basic realm="mw", Cache-Control: private, max-age=7 and x-custom: mw. The edge route returns Cache-Control: no-store, x-custom: handler, Set-Cookie: h=1, WWW-Authenticate: Bearer realm="h".

Header Next.js (dev and prod) vinext hybrid prod before vinext Pages-only prod before vinext dev before vinext after (all paths)
Cache-Control private, max-age=7 + no-store private, max-age=7 no-store no-store private, max-age=7, no-store
x-custom mw + handler mw handler handler mw, handler
WWW-Authenticate Basic mw + Bearer h Basic mw (handler's dropped) Bearer h Bearer h Basic mw, Bearer h
Proxy-Authenticate Basic mw Basic mw Basic mw Basic mw Basic mw
Set-Cookie mw, h mw, h mw, h h mw, h
Content-Type one, the handler's one one one one

Repeated non-cookie values are joined with ", ", which is how Fetch Headers represent repeated fields. A Fetch client reads the same value from Next's separate header lines.

Other cases checked against next start:

  • A header only middleware sets is kept.
  • Both Vary values are sent.
  • A custom x-middleware-foo from both sides is sent twice.
  • When both set Content-Type, Next sends both values. vinext follows the same composition.
  • Middleware's values replace next.config headers of the same name before the handler's are appended.
  • When middleware and the handler both send x-custom: mw, Cache-Control: private, max-age=7 and Set-Cookie: mw=1, each value is sent once.

Fix

  • pages-api-route.ts: the edge branch now copies the pre-handler headers the way a Node handler's res is seeded (next.config headers first, then middleware's on top, then middleware's cookies) and appends every value from the edge Response that the header doesn't already carry, cookies included. This replaces fix(pages): let API route headers override middleware headers #4056's middleware-wins merge. The hybrid bridge, the hybrid multi-stage response stage and the Pages Workers/multi-stage response stage already pass these headers in, so all of them now compose the same way.
  • prod-server.ts: the Pages-only Node server now passes the pipeline's staged headers, minus Set-Cookie and Content-Length, to the API route, as the Workers response stage does. The pipeline still puts middleware's cookies ahead of the handler's. A Node handler now also sees those headers on res before it runs, as it does in Next.js and on Workers.
  • api-handler.ts (dev): edge response headers are appended onto the Node response with the same existing-value check as NodeNextResponse.appendHeader(), instead of replacing middleware's with setHeader().
  • No stage protocol change. The multi-stage gateway's existing fill-missing merge is a no-op on these responses, and it still puts middleware's cookies first.

Tests

  • tests/pages-api-route.test.ts:
    • An edge handler's headers are appended after next.config and middleware headers (singular values, challenges, cookies, a single Content-Type, status).
    • Values already present are skipped.
    • The staged-cookie test no longer expects a repeated staged cookie.
  • tests/api-handler.test.ts: the dev edge path appends after middleware's headers and skips values already present. The api-handler-globals mock response gains getHeader(), like a Node response.
  • New middleware-header-precedence-edge API routes and middleware branches in pages-basic and app-basic. The existing multi-stage-http route now also sets Cache-Control.
  • Integration tests:
    • tests/pages-router.test.ts: Pages-only prod and dev
    • tests/app-router-production-server.test.ts: hybrid prod
    • tests/multi-stage-http.test.ts: replaces fix(pages): let API route headers override middleware headers #4056's middleware-wins expectation. That fixture's CDN adapter turns any private Cache-Control into no-store, so the test asserts that value.
  • All of the new tests fail on the parent commit and pass with the fix.

@james-elicx
james-elicx added this pull request to stack #4080 October 17, 2026 23:58
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues
Please review exact head e4fd6d5 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 17, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@vinext/cloudflare

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

create-vinext-app

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

@vinext/types

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

vinext

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

@cloudflare/workers-response-store

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

commit: 1ca1856

@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

}
for (const cookie of middlewareHeaders?.getSetCookie() ?? [])
headers.append("Set-Cookie", cookie);
for (const [name, value] of response.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.

Next.js does not actually emit an exact duplicate here: NodeNextResponse.appendHeader() reads the existing values and only adds value when it is not already present. Headers.append() always adds it, so if middleware and the edge handler both return (for example) x-custom: same or the same Set-Cookie, Workers/hybrid/prod now send same, same while Next.js sends it once. The dev implementation at api-handler.ts:452-455 has the same issue because native ServerResponse.appendHeader() also does not perform Next’s wrapper-level deduplication. Please preserve the existing-value check in both paths, including per-cookie values.

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Review: 1 findings.

Posted 1 actionable inline comment.

github run

@github-actions

github-actions Bot commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 1ca1856 against base bfa9fe0 using alternating same-runner rounds. Next.js was unchanged and skipped.

1 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 154.2 KB 154.2 KB ⚫ +0.0%
Client entry size (gzip) vinext 141.7 KB 141.7 KB ⚫ -0.0%
Dev server cold start vinext 2.37 s 2.31 s 🟢 -2.4%
Dev server cold start (large Pages Router app) vinext 19.67 s 19.59 s ⚫ -0.4%
Production build time vinext 2.66 s 2.67 s ⚫ +0.5%
RSC entry closure size (gzip) vinext 142.0 KB 142.0 KB ⚫ +0.0%
Server bundle size (gzip) vinext 249.6 KB 249.6 KB ⚫ +0.0%

View detailed results and traces

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

@james-elicx
james-elicx force-pushed the fix/pages-edge-api-header-append branch from e4fd6d5 to 417fc93 Compare October 18, 2026 00:15
@james-elicx

Copy link
Copy Markdown
Member Author

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

// cookies are added ahead of the handler's by the pipeline.
const initialResponseHeaders = new Headers(stagedHeaders);
initialResponseHeaders.delete("Content-Length");
initialResponseHeaders.delete("Set-Cookie");

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.

initialResponseHeaders drops Set-Cookie, so the duplicate check in appendEdgeApiResponseHeaders() never sees middleware cookies in the Pages-only production path. pages-request-pipeline.ts later prepends the staged cookies in mergeHeaders() without deduplicating them. Consequently, when middleware and the edge handler both emit the same cookie, this path still returns it twice even though Next.js returns it once. Keep the cookies available for the handler-level deduplication and strip the inherited copy before the pipeline recomposes them (as the response-stage path does), or deduplicate at this boundary.

]);
const append = (name: string, value: string) => {
const current = values.get(name) ?? [];
if (!current.includes(value)) values.set(name, [...current, value]);

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 exact-value deduplication removes the evidence that the edge handler authored a CDN policy. If middleware and the handler both set the same provider cache header, the merged response equals middlewareResponseHeaders, so hasHandlerCdnResponsePolicy() returns false. The base implementation explicitly marked the handler policy before merging; without that marker, explicitProviderOutcome() can reject shared-cache admission for a handler-authored policy. Record the edge response policy before composition, including when its value duplicates middleware.

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Review: 2 findings.

Posted 2 actionable inline comments. Targeted tests passed: 130/130.

github run

@james-elicx
james-elicx force-pushed the fix/pages-edge-api-header-append branch from 417fc93 to 25ddebb Compare October 18, 2026 22:01
@james-elicx

Copy link
Copy Markdown
Member Author

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

@ask-bonk

ask-bonk Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

LGTM!

github run

Next.js runEdgeFunction() appends every header of an edge API route's
Response onto the Node response that already holds next.config and
middleware headers, so a header both set carries middleware's value and
then the handler's. Its NodeNextResponse.appendHeader() skips a value the
header already carries, so a value both sides set (or a repeated cookie) is
sent once. vinext let middleware win in hybrid apps (dropping the handler's
WWW-Authenticate), let the handler win in Pages-only production, and in dev
also replaced middleware's cookie.

The Pages API route now builds an edge response from the pre-handler
headers with the handler's values appended unless already present, and the
Pages-only production server hands it the staged middleware and
next.config headers, as the Workers response stage already does. Dev ports
the same existing-value check onto the Node response.
@james-elicx
james-elicx force-pushed the fix/pages-edge-api-header-append branch from 25ddebb to 1ca1856 Compare October 18, 2026 22:13
@james-elicx
james-elicx marked this pull request as ready for review October 18, 2026 22:28
@clanky-the-clanker

Copy link
Copy Markdown

💬 Approved with comments — I found no correctness, security or performance problems. Two INFO notes: the description says Set-Cookie is removed from the staged headers but the code keeps it, and the multi-stage test cannot confirm the Cache-Control append.

Findings: 2 info

The decision is advisory: this comment never approves, blocks or requests changes on the pull request.

🔧 Fix with your agent

Paste the following into your coding agent to investigate and fix these issues.

Fix the following code review issues found in pull request #4079.

Before making changes, review each issue and present a brief summary table:
- For each finding, state whether you agree, disagree, or need clarification
- If you disagree (e.g., the fix would require disproportionate changes
  for minimal ROI, or the finding is factually incorrect), explain why
- If you need to clarify something before deciding, ask those questions
- Then share your plan for which issues to tackle and in what order

## INFO (2)

### [Code Quality] The description says staged headers are passed "minus Set-Cookie and Content-Length", but only Content-Length is removed. The cookies are kept and the inherited copy is stripped from the response afterwards. The behaviour is correct; only the wording is off.
- **File:** packages/vinext/src/server/prod-server.ts:2820

### [Code Quality] The multi-stage-http Cache-Control assertion (`no-store`) would also pass if the append were broken, because the fixture's CDN adapter rewrites private Cache-Control. Other tests do cover the composition.
- **File:** tests/multi-stage-http.test.ts:467

📋 Code Quality: 💬 2 info

The change is coherent and I found no correctness bugs. Two notes: the description doesn't match the code on Set-Cookie, and one test assertion is weaker than the others.

Recommendation: 💬 Optional Nits

  • [INFO] The description says staged headers are passed "minus Set-Cookie and Content-Length", but only Content-Length is removed. The cookies are kept and the inherited copy is stripped from the response afterwards. The behaviour is correct; only the wording is off. (packages/vinext/src/server/prod-server.ts:2820)
  • [INFO] The multi-stage-http Cache-Control assertion (no-store) would also pass if the append were broken, because the fixture's CDN adapter rewrites private Cache-Control. Other tests do cover the composition. (tests/multi-stage-http.test.ts:467)
View full review
  • Description doesn't match the code (packages/vinext/src/server/prod-server.ts:2819-2833).
    • The description says the Pages-only Node server passes the staged headers "minus Set-Cookie and Content-Length", but the code only removes Content-Length.
    • The staged cookies stay in initialResponseHeaders, so a Node handler sees them on res. stripInheritedResponseStageCookies then removes the copy the response inherited, because the pipeline adds middleware's cookies ahead of the handler's.
    • The behaviour is correct and matches the Workers response stage. Consider correcting the description so it doesn't mislead later readers.
  • Weaker multi-stage assertion (tests/multi-stage-http.test.ts:467-485).
    • That fixture's CDN adapter turns any private Cache-Control into no-store, so this assertion would also pass if only the handler's value got through.
    • The unit tests and the Pages-only and hybrid production integration tests do assert private, max-age=7, no-store, so the behaviour is covered elsewhere.
    • Optionally use a header the adapter does not rewrite, so this suite checks the composition too.

Also checked and correct:

  • Cookie ordering and dedup: middleware's cookies come first, then the handler's, with no repeats.
  • The cache-policy marker and the streamed-response marker are both still preserved.
  • The dev appendHeader path uses res.getHeader, which both the real Node response and the test mocks provide.

✅ All other checks passed

🔒 Security: ✅ LGTM

No security issues found in the header-merging changes.


📖 AGENTS.md: ✅ LGTM

The change follows the repository guidelines. The dev and production paths are both updated, and the fixtures and integration tests are included.


⚡ Performance: ✅ LGTM

No performance concerns.


📝 Documentation: ✅ LGTM

No documentation issues.


Reviewed commit: 1ca18565

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