The answer. Agents increasingly request pages with Accept: text/markdown. On Pages you can honour that without any framework feature: have the static build emit a Markdown sibling for each route (/about → /about.md, / → /index.md), then add functions/_middleware.js, which runs on every request. On a matching Accept header it fetches the sibling through env.ASSETS.fetch() — the binding to your own built output — and returns it as text/markdown with Vary: Accept, so caches keep the HTML and Markdown variants apart. Everything else, including paths that aren't pages, falls through to next(). No origin change, no second host; the HTML site is untouched for humans.
The pattern.
// functions/_middleware.js — Markdown content negotiation for a static Pages site
export async function onRequest({ request, next, env }) {
const url = new URL(request.url);
const wantsMd = /text\/markdown/i.test(request.headers.get("accept") || "");
const path = url.pathname;
const isAsset = /\.[a-z0-9]+$/i.test(path); // /app.css, /x.png, /about.md itself
const isReserved = path.startsWith("/.well-known/") || path === "/mcp" || path.startsWith("/admin");
if (request.method === "GET" && wantsMd && !isAsset && !isReserved) {
const clean = path.replace(/\/+$/, "");
const md = await env.ASSETS.fetch(new URL((clean === "" ? "/index" : clean) + ".md", url.origin));
if (md.ok) {
return new Response(md.body, {
status: 200,
headers: { "content-type": "text/markdown; charset=utf-8", vary: "Accept",
"access-control-allow-origin": "*", "cache-control": "public, max-age=0, must-revalidate" },
});
}
}
return next();
}
// src/pages/about.md.ts (Astro) — emit the sibling at build time
export const GET = () => new Response(renderMarkdown(about), { headers: { "content-type": "text/markdown" } });
The receipt. On our lab's Astro site on Pages, shipped 2026-06-11 in one agent-readiness commit (Link headers, llms-full.txt, Markdown negotiation and an MCP server as a Pages Function): the build emits index.md and about.md twins from src/pages/*.md.ts, and the 37-line middleware serves them to anything sending Accept: text/markdown. The deploy path didn't change — GitLab CI runs npm run build and wrangler pages deploy dist on the production branch, and the middleware ships with the functions/ directory. Markdown-negotiated request counts are not measured; the point is that it costs one file and no infrastructure.
Watch out.
- Exclude asset paths and reserved routes explicitly; otherwise
/logo.pngwith a broadAcceptheader triggers a 404 hunt for/logo.png.md. Vary: Acceptis not optional. Without it a cached Markdown response can be served to a browser.- Falling back to
next()when the twin is missing keeps every page working during a partial rollout.
Related: mcp-server-as-pages-function-stateless-streamable-http · pages-production-branch-not-main-alias-url-tell