Table of contents
- Quick summary
- Why Astro needs a server for this
- Step 1: switch the build to on-demand rendering
- Step 2: add the Markdown endpoint
- Step 3: route by Accept header in middleware
- Step 4: send the same URL different Accept values
- The prerender trap on a hybrid site
- Static hosting without an adapter
- Check it: scan first, curl second
- What AIScan cannot see, and limits
- Next step
An AI agent that sends Accept: text/markdown to an Astro page gets the rendered HTML back, because Astro does not negotiate content types on its own. AIScan check C1 grades exactly that request. This guide adds a Markdown representation at the same URL with one middleware file and one endpoint, tested on Astro 7.3.5, @astrojs/node 11.1.6 and Node 22.22.0 on 2 October 2026. Browsers keep getting HTML and agents get the page source from your content collection.
Quick summary
| If you want to... | Do this | Time |
|---|---|---|
| Serve Markdown on the same URL | src/middleware.ts that reads Accept and calls next() with a Markdown path | 10 min |
| Produce the Markdown | One on-demand endpoint that returns the collection entry's raw body | 5 min |
| Keep caches honest | Vary: Accept on both representations | 2 min |
| Confirm it works | npx aiscan-cli yoursite.com, then read the C1 row | 1 min |
Why Astro needs a server for this
Check C1 re-requests a page with Accept: text/markdown and passes when the server answers text/markdown. That is proactive negotiation as defined in RFC 9110: one URL, several representations, picked by a request header. A /page.md sidecar does not satisfy it, because the scanner asks the original URL.
According to the Astro on-demand rendering guide, pages are prerendered at build time by default, and rendering on the server needs an adapter. A prerendered page is a file. A file cannot look at a request header, so the negotiation has to run at request time. That is the whole reason this guide uses output: 'server'.
Step 1: switch the build to on-demand rendering
- Run
npx astro add nodeand accept the prompts. The command installs@astrojs/nodeand edits your config. - Open
astro.config.mjsand confirm it matches the block below.
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
});
If you deploy to Netlify, Vercel or Cloudflare, install that adapter instead. The middleware and endpoint below do not change.
Step 2: add the Markdown endpoint
The endpoints guide says a file in src/pages that exports a GET function becomes a route. Create src/pages/md/[...slug].ts. It reads from a content collection named docs (a glob loader over src/content/docs/**/*.md), so change the collection name to yours.
import type { APIRoute } from 'astro';
import { getEntry } from 'astro:content';
export const GET: APIRoute = async ({ params }) => {
const entry = await getEntry('docs', params.slug!);
if (!entry) return new Response('Not found', { status: 404 });
const body = `# ${entry.data.title}\n\n${entry.body}\n`;
return new Response(body, {
headers: { 'Content-Type': 'text/markdown; charset=utf-8', Vary: 'Accept' },
});
};
entry.body is the raw Markdown of the file without its frontmatter, so the response is your own source and no HTML-to-Markdown converter is involved. The title is prepended because it lives in frontmatter, not in the body.
Step 3: route by Accept header in middleware
Per the middleware guide, next() accepts a path and rewrites the request in place, without a redirect and without running middleware a second time. Create src/middleware.ts:
import { defineMiddleware } from 'astro:middleware';
function prefersMarkdown(accept: string | null): boolean {
if (!accept) return false;
const q = (type: string) => {
for (const part of accept.split(',')) {
const [t, ...params] = part.trim().split(';');
if (t.trim().toLowerCase() !== type) continue;
const qp = params.find((p) => p.trim().startsWith('q='));
return qp ? parseFloat(qp.trim().slice(2)) : 1;
}
return 0;
};
const md = q('text/markdown');
return md > 0 && md >= q('text/html');
}
export const onRequest = defineMiddleware(async (context, next) => {
const { pathname } = context.url;
if (
context.request.method === 'GET' &&
pathname.startsWith('/docs/') &&
prefersMarkdown(context.request.headers.get('accept'))
) {
return next('/md' + pathname.slice('/docs'.length));
}
const res = await next();
if (pathname.startsWith('/docs/')) res.headers.append('Vary', 'Accept');
return res;
});
Markdown wins only when it is listed, is not q=0, and ranks at least as high as text/html. A browser sending text/html,*/* stays on HTML. The last three lines mark the HTML response as Vary: Accept, so a cache cannot hand a browser the Markdown copy. Change the /docs/ prefix to wherever your pages live.
Step 4: send the same URL different Accept values
Build and start the server with npx astro build and HOST=127.0.0.1 PORT=4321 node dist/server/entry.mjs, then request one URL several ways. These are the results from the test app:
| Accept header | Status | Content-Type | Body starts with |
|---|---|---|---|
| none | 200 | text/html | <!DOCTYPE html> |
text/markdown | 200 | text/markdown; charset=utf-8 | # Hello Astro |
text/markdown, text/html;q=0.8 | 200 | text/markdown; charset=utf-8 | # Hello Astro |
text/html, text/markdown;q=0.5 | 200 | text/html | <!DOCTYPE html> |
*/* | 200 | text/html | <!DOCTYPE html> |
text/markdown;q=0 | 200 | text/html | <!DOCTYPE html> |
Every row carried Vary: Accept. An unknown slug with Accept: text/markdown returned a real 404 from the endpoint, so a missing page never turns into an empty 200.
The prerender trap on a hybrid site
Pages you mark export const prerender = true do not reach the middleware at request time. I set one docs route to prerender, rebuilt, and requested it with Accept: text/markdown. The Node adapter served the built index.html, with Content-Type: text/html; charset=utf-8 and no Vary header, for both /docs/hello and /docs/hello/. The build also logged that Astro.request.headers is not available on prerendered pages.
| Your setup | Does negotiation work? | What to do |
|---|---|---|
output: 'server', no prerender = true on docs routes | Yes | Steps 1 to 3 |
output: 'static' with the docs routes opted out via prerender = false | Yes | Same files, plus the adapter |
| Docs routes prerendered | No | Remove prerender = true from them |
| Pure static site, no adapter | Not in Astro | Use a host rule (below) |
Static hosting without an adapter
A static build has no request-time code, so Astro alone cannot satisfy C1. You can still emit the Markdown files at build time with a src/pages/docs/[...slug].md.ts endpoint that exports getStaticPaths(). I built that on a static project and it wrote dist/docs/hello.md. The Accept rewrite then has to live on your host or CDN, and each host expresses that differently. I did not test any host here, so follow your host's rewrite documentation and finish with the check below.
Check it: scan first, curl second
Run the scanner against the live site and read the C1 row:
npx aiscan-cli yoursite.com
By hand, use two requests to one URL and compare:
curl -sI -H "Accept: text/markdown" https://yoursite.com/docs/hello
curl -sI -H "Accept: text/html" https://yoursite.com/docs/hello
Pass looks like content-type: text/markdown on the first, content-type: text/html on the second, and vary: accept on both. If both return HTML, read the prerender section above first.
What AIScan cannot see, and limits
The scan makes one request from one place. It cannot tell you that a CDN in another region cached the wrong variant, and it cannot judge whether your Markdown reads well. Open one converted page and read it the way an agent would.
This setup covers entries in one content collection. Pages built from .astro components alone, such as a landing page, have no Markdown source, so they still return HTML. MDX entries return their raw MDX, including any component tags, because entry.body is the file text. Re-run the scan after each adapter or hosting change.
Next step
Run npx aiscan-cli yoursite.com and read C1. The same check on other stacks is in serving text/markdown on Next.js and on WordPress. Astro's other readiness files are covered in publishing llms.txt on Astro, and the population-level numbers are in documentation sites and AI agent readiness. The check page is /docs/checks/content and every other fix is in /guides.
Frequently asked questions
How do I make Astro return Markdown when an agent sends Accept: text/markdown?
Render on demand with an adapter, add src/middleware.ts that checks the Accept header and calls next() with a Markdown path, and add an endpoint that returns the collection entry's raw body as text/markdown. On Astro 7.3.5 the same URL returned text/markdown to one request and text/html to the other.
I added the middleware but curl still returns text/html. Why?
The most common cause is a prerendered route. Pages with prerender set to true are served as built files and never reach the middleware. Remove that export from the docs routes, rebuild, and request the URL again with the Accept header.
My static Astro build ignores Accept. What happened?
A static build has no request-time code, so Astro cannot read the header. Add an adapter and on-demand rendering, or emit .md files at build time and add an Accept rewrite on your host or CDN, then re-test with curl.
Agents get HTML, or browsers get Markdown, after a CDN warm-up. How do I fix it?
The cache stored one variant for the URL. Send Vary: Accept on both responses, as the middleware above does, and make the CDN honour it or bypass the cache when Accept contains text/markdown. Purge the page and request it twice with each header.
Why does the endpoint return Not found for a page that exists?
The slug passed to getEntry must equal the collection entry id. With the glob loader the id is the file path without the extension, so docs/hello.md becomes hello. Check the collection name and the route prefix in the middleware.
Does a Markdown copy at the same URL cause duplicate content in search?
The URL is the same, so there is only one page to index. The response carries Vary: Accept so caches and crawlers know the body depends on the header. Browsers and Googlebot ask for HTML and keep receiving it.
Does this cover pages built from .astro components alone?
No. The endpoint reads a content collection, so a landing page with no Markdown source still returns HTML. MDX entries return their raw MDX text, including component tags.
Can I use a different adapter than Node?
Yes. The middleware and the endpoint use only Astro's own APIs. Install the adapter for Netlify, Vercel or Cloudflare with astro add, keep output set to server, and run the same curl pair against the deployed URL.
Related guides
What a Failing AIScan Check Really Says: 2,961 Fail Lines Audited
A failing check on a scan report is supposed to be an instruction. The site owner reads the line, knows what the scanner asked for, knows what came back, and knows what to change. We audited whether…
Rate-limit AI crawlers without blocking them
Most advice on rate limiting AI crawlers stops at "add a limit". The part that costs you is where the limit applies and what it answers. A limit that covers /robots.txt and /llms.txt makes your own…
Which Agent-Readiness Checks Travel Together: 739 Sites, Three Bundles
Fix one agentreadiness check and a second one often moves with it. Fix another and nothing else changes. A scan report lists every check as its own line, so the reader cannot see which lines are…
Platform League Table: WordPress, Next.js, Astro and Shopify Across 728 Sites
Every few weeks someone asks which platform produces the most agentready sites. WordPress, Next.js, Astro and Shopify all have fans, and a ranked table is the answer people expect. We have the data…
