---
title: "How to serve text/markdown to AI agents on Astro"
slug: markdown-for-ai-agents-astro
published: 2026-10-02T13:16:26.691123+00:00
updated: 2026-10-04T12:06:21.854556+00:00
author: "AIScan Content Team"
author_url: https://aiscan.site/about
category: "AI Readiness"
tags: check:C1, platform:astro, Astro, Markdown, content negotiation, middleware, AI agents
description: "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."
url: https://aiscan.site/blog/markdown-for-ai-agents-astro
---

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](https://www.rfc-editor.org/rfc/rfc9110.html#name-proactive-negotiation): 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](https://docs.astro.build/en/guides/on-demand-rendering/), 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.

```js
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](https://docs.astro.build/en/guides/endpoints/) 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.

```ts
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](https://docs.astro.build/en/guides/middleware/), `next()` accepts a path and rewrites the request in place, without a redirect and without running middleware a second time. Create **`src/middleware.ts`**:

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

```bash
npx aiscan-cli yoursite.com
```

By hand, use two requests to one URL and compare:

```bash
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](https://aiscan.site/blog/markdown-for-ai-agents-nextjs) and [on WordPress](https://aiscan.site/blog/markdown-for-ai-agents-wordpress). Astro's other readiness files are covered in [publishing llms.txt on Astro](https://aiscan.site/blog/llms-txt-astro), and the population-level numbers are in [documentation sites and AI agent readiness](https://aiscan.site/blog/documentation-sites-ai-agent-readiness). The check page is [/docs/checks/content](https://aiscan.site/docs/checks/content) and every other fix is in [/guides](https://aiscan.site/guides).

