Serving Your Blog as Markdown So AI Agents Can Actually Read It
Last updated: 15.03.2026

Audio Narration
Listen to this post
The article explains how to serve clean Markdown versions of blog posts so AI agents can consume content without HTML noise like navigation, scripts, and cookie banners. It recommends two access patterns: appending .md to post URLs, or using an Accept: text/markdown header for content negotiation. In Next.js, rewrites in next.config.ts route both patterns to an internal /md/posts/[slug] handler. That route fetches the post from Sanity, converts it to Markdown, and returns it with a text/markdown Content-Type and short caching headers. A buildPostMarkdown helper constructs the Markdown document with a title, canonical URL, optional hero image, auto-generated summary, and body converted from Portable Text via @portabletext/markdown. Code blocks stored in Sanity as _type: "code" are correctly rendered as fenced Markdown code blocks with language tags, making them ideal for AI agents and syntax highlighters. A /posts.md index provides a machine-readable sitemap listing all posts with metadata and links, enabling agents to discover and traverse content efficiently using either the .md suffix or Accept header approach.
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:
1rewrites: async () => ({
2 beforeFiles: [
3 // Explicit .md URL
4 { source: '/posts/:slug.md', destination: '/md/posts/:slug' },
5 // Content negotiation via Accept header
6 {
7 source: '/posts/:slug',
8 destination: '/md/posts/:slug',
9 has: [{ type: 'header', key: 'accept', value: '(.*)text/markdown(.*)' }],
10 },
11 ],
12})1rewrites: async () => ({
2 beforeFiles: [
3 // Explicit .md URL
4 { source: '/posts/:slug.md', destination: '/md/posts/:slug' },
5 // Content negotiation via Accept header
6 {
7 source: '/posts/:slug',
8 destination: '/md/posts/:slug',
9 has: [{ type: 'header', key: 'accept', value: '(.*)text/markdown(.*)' }],
10 },
11 ],
12})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:
1export async function GET(request, { params }) {
2 const data = await sanityFetch({ query: postQuery, params: await params })
3 if (!data) return new Response('Not found', { status: 404 })
4
5 const markdown = buildPostMarkdown(
6 data,
7 process.env.NEXT_PUBLIC_SITE_URL
8 )
9 return new Response(markdown, {
10 headers: {
11 'Content-Type': 'text/markdown; charset=utf-8',
12 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
13 },
14 })
15}1export async function GET(request, { params }) {
2 const data = await sanityFetch({ query: postQuery, params: await params })
3 if (!data) return new Response('Not found', { status: 404 })
4
5 const markdown = buildPostMarkdown(
6 data,
7 process.env.NEXT_PUBLIC_SITE_URL
8 )
9 return new Response(markdown, {
10 headers: {
11 'Content-Type': 'text/markdown; charset=utf-8',
12 'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
13 },
14 })
15}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.
1export function buildPostMarkdown(post, baseUrl) {
2 const parts = []
3 parts.push(`# ${post.title}`)
4 parts.push(`**URL:** ${baseUrl}/${post.path}`)
5 if (post.mainImage) {
6 parts.push(``)
7 }
8 if (post.autoSummary) parts.push(`**Summary:** ${post.autoSummary}`)
9 if (post.body) parts.push(convertToMarkdown(post.body, baseUrl))
10 return parts.join('\n')
11}1export function buildPostMarkdown(post, baseUrl) {
2 const parts = []
3 parts.push(`# ${post.title}`)
4 parts.push(`**URL:** ${baseUrl}/${post.path}`)
5 if (post.mainImage) {
6 parts.push(``)
7 }
8 if (post.autoSummary) parts.push(`**Summary:** ${post.autoSummary}`)
9 if (post.body) parts.push(convertToMarkdown(post.body, baseUrl))
10 return parts.join('\n')
11}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:
1{
2 "_type": "code",
3 "language": "ts",
4 "code": "type StrictOmit<T, K extends keyof T> = Omit<T, K>;"
5}1{
2 "_type": "code",
3 "language": "ts",
4 "code": "type StrictOmit<T, K extends keyof T> = Omit<T, K>;"
5}@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:
1```ts
2type StrictOmit<T, K extends keyof T> = Omit<T, K>;
3```1```ts
2type StrictOmit<T, K extends keyof T> = Omit<T, K>;
3```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
1# .md URL suffix
2curl https://andreskristensen.blog/posts/reading-time-sanity-blog.md
3
4# Accept header (content negotiation)
5curl -H 'Accept: text/markdown' https://andreskristensen.blog/posts/reading-time-sanity-blog
6
7# Posts index — discover all posts
8curl https://andreskristensen.blog/posts.md1# .md URL suffix
2curl https://andreskristensen.blog/posts/reading-time-sanity-blog.md
3
4# Accept header (content negotiation)
5curl -H 'Accept: text/markdown' https://andreskristensen.blog/posts/reading-time-sanity-blog
6
7# Posts index — discover all posts
8curl https://andreskristensen.blog/posts.mdThe .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.