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

// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';

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 — 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:

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

Page / endpoint

Option A — conditional rendering inside a page:

---
// src/pages/blog/[slug].astro
import { getEntry } from 'astro:content';

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:

// src/pages/api/markdown/[...slug].ts
import type { APIRoute } from 'astro';
import { getEntry } from 'astro:content';

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 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 or Caddy to negotiate between them.

Verify

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