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: Acceptnever 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: Accepton 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
Varyheader.
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
.htmland.mdat 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