# Astro

Middleware-driven negotiation for Astro, with adapter-specific notes for Cloudflare, Vercel, and Node.

Astro's middleware is the right place for Accept negotiation — but
only when it actually runs at **request time**. Astro's default
(`output: 'static'`) prerenders every page to HTML at build time;
middleware runs once during the build and the emitted HTML is then
served by your CDN or asset host without ever invoking your middleware
again. That means canonical-URL Accept negotiation silently breaks.

**Set `output: 'server'` in `astro.config.mjs`** (or mark individual
pages with `export const prerender = false`) so every request goes
through your middleware at runtime.

## Middleware

```ts
// src/middleware.ts

const PRODUCES = ['text/html', 'text/markdown'];

type AcceptEntry = { type: string; q: number; specificity: number };

function parseAccept(header: string): AcceptEntry[] {
  return header
    .split(',')
    .map((raw) => {
      const parts = raw.trim().split(';').map((s) => s.trim());
      const type = parts[0].toLowerCase();
      let q = 1;
      for (const param of parts.slice(1)) {
        const [name, value] = param.split('=').map((s) => s.trim());
        if (name === 'q') {
          const parsed = Number(value);
          if (!Number.isNaN(parsed)) q = Math.max(0, Math.min(1, parsed));
        }
      }
      const specificity = type === '*/*' ? 0 : type.endsWith('/*') ? 1 : 2;
      return { type, q, specificity };
    });
  // Preserve client order — position is used for tiebreaks below.
}

function matches(entry: AcceptEntry, candidate: string): boolean {
  if (entry.type === '*/*') return true;
  if (entry.type.endsWith('/*')) return candidate.startsWith(entry.type.slice(0, -1));
  return entry.type === candidate;
}

function preferredType(header: string | null): string | null {
  if (!header) return PRODUCES[0];
  const entries = parseAccept(header);
  if (entries.length === 0) return PRODUCES[0];

  let best: string | null = null;
  let bestQ = -1;
  let bestPosition = Infinity;

  for (const candidate of PRODUCES) {
    // For each candidate, find the *most specific* matching range.
    // RFC 9110 §12.5.1: specific ranges override less specific ones
    // regardless of q — so `text/html;q=0, */*;q=1` correctly rejects
    // text/html instead of letting the wildcard override.
    let matched: AcceptEntry | null = null;
    let matchedPosition = Infinity;
    for (let idx = 0; idx < entries.length; idx++) {
      const e = entries[idx];
      if (!matches(e, candidate)) continue;
      if (
        matched === null ||
        e.specificity > matched.specificity ||
        (e.specificity === matched.specificity && idx < matchedPosition)
      ) {
        matched = e;
        matchedPosition = idx;
      }
    }
    if (matched === null) continue;
    const matchedQ: number = matched.q;
    if (matchedQ <= 0) continue; // explicit rejection

    // Across candidates: highest q wins; tie-break on client order
    // so `Accept: text/markdown, text/html, */*` picks text/markdown.
    if (matchedQ > bestQ || (matchedQ === bestQ && matchedPosition < bestPosition)) {
      bestQ = matchedQ;
      bestPosition = matchedPosition;
      best = candidate;
    }
  }

  return best;
}

function appendVaryAccept(headers: Headers): void {
  const existing = headers.get('Vary');
  if (!existing) {
    headers.set('Vary', 'Accept');
    return;
  }
  const tokens = existing.split(',').map((s) => s.trim().toLowerCase());
  if (!tokens.includes('accept')) {
    headers.set('Vary', `${existing}, Accept`);
  }
}

export const onRequest = defineMiddleware(async (ctx, next) => {
  const chosen = preferredType(ctx.request.headers.get('accept'));
  ctx.locals.prefersMarkdown = chosen === 'text/markdown';

  const response = await next();
  appendVaryAccept(response.headers);
  return response;
});
```

That's the same proper-negotiation parser used in the
[Accept parsing guide](/guides/accept-parsing) — respects q-values,
breaks ties by specificity, and honors `q=0` explicit rejections.
The middleware sets `ctx.locals.prefersMarkdown` so downstream pages
and endpoints can branch on it (shown below).

Declare the `locals` type so pages get intellisense:

```ts
// src/env.d.ts
declare namespace App {
  interface Locals {
    prefersMarkdown: boolean;
  }
}
```

## Page / endpoint

Option A — conditional rendering inside a page:

```astro
---
// src/pages/blog/[slug].astro

const { slug } = Astro.params;
const post = await getEntry('blog', slug!);
if (!post) return Astro.redirect('/404');

if (Astro.locals.prefersMarkdown) {
  return new Response(post.body, {
    status: 200,
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Vary': 'Accept',
    },
  });
}
---
<Layout>
  <article>{post.body}</article>
</Layout>
```

Option B — a separate endpoint that middleware rewrites to:

```ts
// src/pages/api/markdown/[...slug].ts


export const GET: APIRoute = async ({ params }) => {
  const post = await getEntry('blog', params.slug!);
  if (!post) return new Response('Not found', { status: 404 });

  return new Response(post.body, {
    headers: {
      'Content-Type': 'text/markdown; charset=utf-8',
      'Vary': 'Accept',
    },
  });
};
```

Option A is simpler for most sites. Option B is cleaner if you have
many page types — one negotiation point, not scattered.

## Adapter-specific notes

### Cloudflare (`@astrojs/cloudflare`)

- Deploys as a Worker with assets; middleware runs at the edge **only
  for non-prerendered routes**. With the default `output: 'static'`,
  Cloudflare's asset server delivers the prerendered HTML directly and
  bypasses your Worker — middleware never runs, `Vary: Accept` never
  gets added, and agents get HTML no matter what they ask for.
- Fix: `output: 'server'` in your Astro config. Every request now
  routes through the Worker and middleware runs per request.
- Cloudflare's edge cache respects `Vary: Accept` on responses the
  Worker emits.
- Or, for zero-config, enable
  [Markdown for Agents](/guides/cloudflare-markdown-for-agents) at the
  edge — your origin stays static, Cloudflare handles negotiation. You
  can turn this on alongside `output: 'server'` if you want both the
  managed conversion and middleware-level control.

### Vercel (`@astrojs/vercel`)

- Middleware runs on Vercel's edge or Node runtime depending on
  adapter config.
- Cache via `Cache-Control: s-maxage=N` — Vercel's edge respects Vary.

### Node (`@astrojs/node`)

- Middleware runs in the Node server. Ensure your reverse proxy
  (Nginx, Caddy) doesn't strip or override the `Vary` header.

### Static (`output: 'static'`)

- Middleware runs once during the build. It does **not** run at
  runtime, so request-time negotiation isn't possible at the Astro
  layer.
- Options: Cloudflare Markdown for Agents at the edge, a Worker that
  proxies static assets and rewrites based on Accept, or pre-render
  both `.html` and `.md` at build time and
  [use Nginx](/recipes/nginx) or [Caddy](/recipes/caddy) to negotiate
  between them.

## Verify

```bash
curl -sI -H "Accept: text/markdown" https://yoursite.com/blog/hello
# Content-Type: text/markdown; charset=utf-8
# Vary: Accept
```