D
P
0
← All articles Baca dalam Bahasa Indonesia

Next.js & React in Production

Astro Middleware Headers Show Up in Dev but Never Reach Prerendered Pages on Vercel

· · 11 min read
Astro Middleware Headers Show Up in Dev but Never Reach Prerendered Pages on Vercel

One response from the Astro dev server was enough to show that something was off. The prerendered / page came back 200 with all four security headers the middleware sets, but the visitor-source cookie written by that same middleware was missing, even though the request carried a Referer from a professional network that should have triggered the classification. The Set-Cookie count was zero. The headers that looked fine in dev were not stored in the build output for Vercel either.

My portfolio site used to run on Next, and there both of these things worked without my ever thinking about them. Four security headers, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and Permissions-Policy, were defined as securityHeaders in next.config.ts and applied through headers() with source: "/:path*". My migration plan notes that this setup put all four on every response, including files in public/. The cookie is called vsrc, written by src/proxy.ts with a 30-day lifetime, sameSite: "lax", and path: "/". The Hero component reads it to shift emphasis slightly: recruiters arriving from a professional network see contact details and the CV first, while developers arriving from a code platform see the debugging-stories angle.

In Next production, that behavior was directly observable. A request with the professional-network Referer got a set-cookie for vsrc with Max-Age=2592000 and SameSite=lax, and 2,592,000 seconds is 60 × 60 × 24 × 30, exactly 30 days. The cookie was set even when the / response was a cache HIT, because Vercel middleware runs before the cache.

The replacement site is Astro with output: 'static'. The migration plan dated September 6, 2026 had already noted that the vsrc cookie cannot come from middleware on a prerendered page, and it handed the problem to a spike before anything was built. That spike ran on September 8, 2026 with Astro 7.3.1, @astrojs/vercel 11.0.10, and Node 22.23.2 on macOS, and its numbers came from commands that were actually run, not from documentation. In that spike, the four security headers and the vsrc cookie were both left to Astro middleware.

The dev server test used the prerendered / page, middleware without an isPrerendered guard (the Astro context property that marks a prerendered route), and that same professional-network Referer:

page /               200, Set-Cookie count: 0
                     but all four security headers PRESENT
asset /favicon.svg   200, zero security headers
on-demand route      200, security headers present

In dev, the middleware does run for prerendered pages. The cookie gets dropped while the headers stay on. Assets in public/ never pass through the middleware at all.

The build names the cause outright. This warning appears three times, once for each prerendered page:

[WARN] `Astro.request.headers` was used when rendering the route `src/pages/index.astro`.
       `Astro.request.headers` is not available on prerendered pages.

Middleware for prerendered pages runs at build time, against a fake request that carries no real headers. There is no visitor at that moment, so a per-visitor cookie structurally cannot be born there. Security headers are a different story, because their values are the same for everyone. Headers like that could be computed once at build time, as long as something stores them.

staticHeaders only carries CSP

My original plan handed that storage job to the Vercel adapter's staticHeaders option. The plan's wording said this option is what writes headers from middleware for prerendered routes into the Vercel configuration. The spike notes call that assumption wrong, and wrong in a way that matters.

What was measured with staticHeaders: true and middleware setting all four security headers: .vercel/output/config.json held 9 routes, and only 2 of them carried headers, a 308 redirect for paths ending in a slash and a cache-control for /_astro/. Zero security headers were stored. vercel.json was not touched either: its sha256 was identical before and after the build.

The reason lives in the adapter source. These are the first five lines of the function that builds header routes from staticHeaders in @astrojs/vercel 11.0.10:

// node_modules/@astrojs/vercel/dist/index.js
function createRoutesWithStaticHeaders(staticHeaders, config) {
  const vercelHeaders = [];
  for (const [pathname, { headers }] of staticHeaders.entries()) {
    if (config.security.csp) {
      const csp = headers.get("Content-Security-Policy");

The function only picks up Content-Security-Policy, and only inside the config.security.csp condition. No other header comes along. The placement itself is not the problem, because when staticHeaders is on, the adapter inserts the routes this function produces right before handle: filesystem. It is just that CSP is the only thing that ever reaches that spot.

The Astro docs are actually consistent with this behavior through the phrase "when provided by Astro features, such as Content Security Policy". The trouble is that the phrase reads like an example, when it is the only case.

Another decision in the plan fell along with it, because it stood on the same premise. The plan had settled on not adding an isPrerendered guard, on the grounds that staticHeaders stores the headers. Since nothing gets stored, the guard now has no effect on headers, so the choice is free. What still holds is that adding it would not fix anything.

Edge mode is only reached after handle: filesystem

For the cookie, the plan prepared two paths. Path A used middlewareMode: 'edge' and removed the isPrerendered guard from visitorSource, on the assumption that edge middleware would reach static pages and deliver full cookie parity. Path B moved the classification into a client script. The plan's recommendation before the spike was to build Path B as the shape that would certainly work, and to move up to Path A only if the spike proved it.

With middlewareMode: 'edge', the adapter does create a separate _middleware.func. What decides the outcome is the order of the routes it writes:

1  ^/(.*)/$              redirect 308
2  handle: filesystem                <- static files are served HERE
3  ^/_astro/(.*)$         cache-control
4  ^/_server-islands/...  -> _middleware
5  ^/_image$              -> _middleware
6  ^/api/...              -> _middleware
7  ^/404/?$               -> _middleware
8  ^/.*$                  -> _middleware, status 404

handle: filesystem sits in second place, and every route that leads to _middleware sits after it. Static pages and public/ assets have already been served before those lines are reached, so Path A was dead.

Production then confirmed it. A curl to a prerendered page with the same Referer produced zero Set-Cookie headers. Path B was no longer just a conclusion drawn from reading the route order, but the result of a measurement.

Headers move to vercel.json

The staticHeaders option was dropped, and the entire header load moved to vercel.json. One thing was still uncertain at that point, namely whether headers in the root vercel.json apply to a deployment built through the Build Output API. The Build Output Configuration docs do not mention any relationship between the two, neither does the Project Configuration page, and the adapter reads vercel.json only to warn about trailingSlash conflicts, not to merge headers. Only a real deployment could answer that question.

The block uses exactly the same values as securityHeaders in Next, with a source that covers every path. I have compacted the whitespace here:

{
  "source": "/(.*)",
  "headers": [
    { "key": "X-Content-Type-Options", "value": "nosniff" },
    { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
    { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
    {
      "key": "Permissions-Policy",
      "value": "camera=(), microphone=(), geolocation=(), browsing-topics=()"
    }
  ]
}

The spike closed the same day, and the remaining items were answered from a real deployment. On the Astro project's production deployment, /, /og-image.png, a .woff2 font file under /fonts/, and /favicon.svg all returned 200 with all four security headers. So vercel.json is honored even when the deployment is built through the Build Output API, for the prerendered page and for the png, woff2, and svg assets alike, and no extra Astro integration injecting routes in astro:build:done is needed.

That test only covered four paths on a project that was still empty, and the plan asks for the same curl -sI to be repeated later against the full path list. When the site shell was finished the same day, a real deployment again showed all four headers on pages and assets. In astro.config.mjs, the decision now lives as a comment stating that staticHeaders and middlewareMode: 'edge' are deliberately not used, with the reason for each.

The load that moved to vercel.json turned out to be more than security headers. An audit after the domain moved over to Astro found all of /_astro/ and /fonts/ served with public, max-age=0, must-revalidate, so a repeat visit paid for up to 59 conditional requests. vercel.json now sets public, max-age=31536000, immutable for /_astro/(.*) and /fonts/(.*). That number is 60 × 60 × 24 × 365 seconds, one year, and the patch was verified against the live site, not against a build.

For the cookie, the answer was Path B, a client script. The classify() logic was copied character for character from the server version: utm_source still wins over the referrer, the four known-source patterns are unchanged, the www. prefix is still stripped, and hosts containing the site's own domain or exactly localhost are still ignored. The only thing that changed is the input, from request headers to document.referrer and location.search. The cookie itself is written in src/scripts/vsrc.ts:

export function tandaiAsalPengunjung(): void {
  if (/(?:^|;\s*)vsrc=/.test(document.cookie)) return;
  const src = classify(document.referrer, new URLSearchParams(location.search));
  if (!src) return;
  document.cookie = `vsrc=${src}; Path=/; Max-Age=2592000; SameSite=Lax`;
}

The cookie is only written if it does not exist yet, with the same attributes as in Next: the whole path, 30 days, SameSite lax. This client-side classification was tested against the Next version on 19 cases, including appended suffixes and utm winning over the referrer, and every result matched.

There is a consequence I had already approved: visitors without JavaScript never get this cookie. Its only reader is the Hero, which also needs JavaScript, so no behavior is lost. The Hero reads the cookie from document.cookie inside a useEffect, and the comment above it still says the source is "set by middleware", even though in Astro a client script writes it. The writer function is imported in the <script> block of the shell layout, Shell.astro, and called after muatBahasaTersimpan() and pasangPenukarBahasa(), before pasangGulirHalus().

The cookie side is not proven end to end. The recorded consequence of Path B is that one parity checklist item has to be rewritten as a Playwright check against document.cookie, and I found no record of that check being run. What production has measured so far is the problem, zero Set-Cookie on prerendered pages, while the fix is only proven at the logic level through those 19 cases.

Where the middleware does run, it overrides the page

The middleware did not disappear from the Astro site. It now only sets headers for on-demand routes, and as of September 8, 2026 the comment in src/middleware.ts says the file duplicates what vercel.json already sets, specifically for 15 on-demand routes that do not go through handle: filesystem.

On those routes the problem ran the other way. For one private route, the middleware set Cache-Control unconditionally, and because it runs after await next(), its value won over the one the page had set. Measured on deployments, Next production sent the page's five-part dynamic value, private, no-cache, no-store, max-age=0, must-revalidate, while Astro sent the two-part value from the middleware. That line was written while the site shell was being built, before the private route existed, and the bug belongs to the same class as a config key written twice: the later one wins silently.

The fix changes the rule, not just the value. The middleware now only fills in Cache-Control on that branch if the response does not already carry one:

if (!response.headers.has('Cache-Control')) {
  response.headers.set('Cache-Control', 'no-store, must-revalidate');
}

One question is still open on that route, because vercel.json also sets Cache-Control for it. One commit concluded that headers in vercel.json override headers set by the page. A later commit took apart the basis for that conclusion: the vercel.json value had been raised while the middleware was still overriding the page header, so there was no way to tell who won. With the middleware fixed, the page should be the one that wins, but the same commit says the winner will be measured on a deployment, and that measurement is not in my notes.

Static files answer first

Summed up from what was measured, on a Vercel deployment, Astro middleware touches prerendered pages only at build time, when there is no visitor yet. staticHeaders in adapter 11.0.10 only carries CSP out of there. Edge middleware routes are reached only after handle: filesystem has finished serving static files. In Next, Vercel middleware runs before the cache, so the cookie still lands on a HIT response, whereas on static Astro the static file is served first. What gets ahead of the middleware here is not configuration inside the framework, but the Vercel routing stage that checks for static files.

Headers whose values are the same for every visitor have a place that is proven to work for prerendered pages and assets, and that place is vercel.json. Per-visitor state on static pages has to be born in the browser, along with its consequence for visitors without JavaScript. Before moving middleware from Next to static Astro on Vercel, open the built .vercel/output/config.json and find where handle: filesystem sits. Routes listed after it will not see prerendered pages or public/ assets, while the dev server keeps putting middleware headers on prerendered pages as if everything were fine.