# Serving Your Blog as Markdown So AI Agents Can Actually Read It

**URL:** https://andreskristensen.blog/post/serving-your-blog-as-markdown-for-ai-agents

![ai agent](https://cdn.sanity.io/images/i6gaeymf/production/eba4ec42b75b86af98229009026182a5a8144430-4896x3264.jpg)


---

**Summary:** The post explains how to serve clean Markdown versions of blog content so AI agents can consume it without HTML noise like navigation, scripts, and cookie banners. Instead of parsing full HTML pages, agents can request Markdown either by appending .md to post URLs or by sending an Accept: text/markdown header.

In Next.js, two rewrite rules in next.config.ts route both patterns to an internal /md/posts/[slug] handler. That route fetches posts from Sanity with sanityFetch, converts them to Markdown, and returns a text/markdown response with short caching headers for freshness.

The buildPostMarkdown function constructs a complete Markdown document: H1 title, canonical URL, optional hero image, auto-generated summary, and the main body converted from Sanity Portable Text via @portabletext/markdown. Code blocks stored in Sanity as custom _type: "code" objects are rendered as fenced code blocks with language tags preserved.

A /posts.md index provides a machine-readable sitemap listing all posts with metadata and links, enabling agents to discover content before fetching individual Markdown posts. The .md suffix is ideal for sharing, while the Accept header method suits tools and AI clients that control HTTP headers.

## Why AI Agents Struggle with HTML

HTML is for browsers. When an AI agent fetches a blog post it gets navigation bars, scripts, cookie banners, and footer links tangled around the actual content. Markdown gives agents clean, structured text they can parse and reason over without stripping noise. Two patterns make this work: append .md to any post URL, or send an Accept: text/markdown header on a normal request.

## Wiring It Up in next.config.ts

Next.js rewrites handle both patterns in beforeFiles. The first rule maps the .md URL suffix to the internal route. The second matches the same destination when the Accept header contains text/markdown:

```json
{
  "_key": "s5",
  "_type": "code",
  "code": "rewrites: async () => ({\n  beforeFiles: [\n    // Explicit .md URL\n    { source: '/posts/:slug', destination: '/md/posts/:slug' },\n    // Content negotiation via Accept header\n    {\n      source: '/posts/:slug',\n      destination: '/md/posts/:slug',\n      has: [{ type: 'header', key: 'accept', value: '(.*)text/markdown(.*)' }],\n    },\n  ],\n})",
  "language": "ts"
}
```

## The Route Handler

The internal /md/posts/[slug] route fetches the post from Sanity via sanityFetch, converts it to Markdown, and returns it with the correct Content-Type and a 60-second cache:

```json
{
  "_key": "s8",
  "_type": "code",
  "code": "export async function GET(request, { params }) {\n  const data = await sanityFetch({ query: postQuery, params: await params })\n  if (!data) return new Response('Not found', { status: 404 })\n\n  const markdown = buildPostMarkdown(\n    data,\n    process.env.NEXT_PUBLIC_SITE_URL\n  )\n  return new Response(markdown, {\n    headers: {\n      'Content-Type': 'text/markdown; charset=utf-8',\n      'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',\n    },\n  })\n}",
  "language": "ts"
}
```

## Building the Markdown Output

buildPostMarkdown assembles the full document: title as H1, canonical URL, hero image if present, auto-generated summary, body converted from Portable Text via @portabletext/markdown, and an optional image gallery.

```json
{
  "_key": "s11",
  "_type": "code",
  "code": "export function buildPostMarkdown(post, baseUrl) {\n  const parts = []\n  parts.push(`# ${post.title}`)\n  parts.push(`**URL:** ${baseUrl}/${post.path}`)\n  if (post.mainImage) {\n    parts.push(`![${post.mainImage.alt}](${post.mainImage.asset?.url})`)\n  }\n  if (post.autoSummary) parts.push(`**Summary:** ${post.autoSummary}`)\n  if (post.body) parts.push(convertToMarkdown(post.body, baseUrl))\n  return parts.join('\\n')\n}",
  "language": "ts"
}
```

## Code Blocks in Portable Text

Sanity stores code snippets as custom blocks with _type: "code". Here is a real example from the TypeScript Pro Essentials post on this blog, as it lives in Portable Text:

```json
{
  "_key": "s14",
  "_type": "code",
  "code": "{\n  \"_type\": \"code\",\n  \"language\": \"ts\",\n  \"code\": \"type StrictOmit<T, K extends keyof T> = Omit<T, K>;\"\n}",
  "language": "json"
}
```

@portabletext/markdown handles this block type and renders it as a fenced code block with the language tag preserved — exactly what an AI agent or syntax highlighter expects:

```json
{
  "_key": "s16",
  "_type": "code",
  "code": "```ts\ntype StrictOmit<T, K extends keyof T> = Omit<T, K>;\n```",
  "language": "md"
}
```

## The Posts Index

/posts.md lists all published posts with titles, dates, authors, summaries, and links. It works as a machine-readable sitemap — an agent can fetch it first to discover what is available, then follow links to individual posts as Markdown.

## Practical Usage

```json
{
  "_key": "s20",
  "_type": "code",
  "code": "# .md URL suffix\ncurl https://andreskristensen.blog/posts/reading-time-sanity-blog.md\n\n# Accept header (content negotiation)\ncurl -H 'Accept: text/markdown' https://andreskristensen.blog/posts/reading-time-sanity-blog\n\n# Posts index — discover all posts\ncurl https://andreskristensen.blog/posts.md",
  "language": "bash"
}
```

The .md URL approach is simpler for linking and sharing. The Accept header approach works with tools that fetch standard URLs but can override headers — useful for MCP servers and AI clients. 



Inspired by [Sanity's field guide on serving content to agents](https://www.sanity.io/blog/how-to-serve-content-to-agents-a-field-guide).