Serve markdown to AI agents on Astro, with the Astro logo — AIScan cover
Serve markdown to AI agents on Astro, with the Astro logo — AIScan cover
AI Readiness

How to serve text/markdown to AI agents on Astro

Return Markdown to AI agents that send Accept: text/markdown on Astro with one middleware file and one endpoint, with Vary: Accept, tested on Astro 7.3.5.

AAIScan Content Team 2 Oct 2026 7 min read
#Astro#Markdown#content negotiation#middleware#AI agents

This guide covers C1 · Content.

Table of contents

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 thisTime
Serve Markdown on the same URLsrc/middleware.ts that reads Accept and calls next() with a Markdown path10 min
Produce the MarkdownOne on-demand endpoint that returns the collection entry's raw body5 min
Keep caches honestVary: Accept on both representations2 min
Confirm it worksnpx aiscan-cli yoursite.com, then read the C1 row1 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

  1. Run npx astro add node and accept the prompts. The command installs @astrojs/node and edits your config.
  2. Open astro.config.mjs and 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 headerStatusContent-TypeBody starts with
none200text/html<!DOCTYPE html>
text/markdown200text/markdown; charset=utf-8# Hello Astro
text/markdown, text/html;q=0.8200text/markdown; charset=utf-8# Hello Astro
text/html, text/markdown;q=0.5200text/html<!DOCTYPE html>
*/*200text/html<!DOCTYPE html>
text/markdown;q=0200text/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 setupDoes negotiation work?What to do
output: 'server', no prerender = true on docs routesYesSteps 1 to 3
output: 'static' with the docs routes opted out via prerender = falseYesSame files, plus the adapter
Docs routes prerenderedNoRemove prerender = true from them
Pure static site, no adapterNot in AstroUse 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