Serving Your Blog as Markdown So AI Agents Can Actually Read It
Last updated: 31.05.2026
Serving Your Blog as Markdown So AI Agents Can Actually Read It
Audio Narration

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:
1rewrites: async () => ({
2 beforeFiles: [
3 // Explicit .md URL
4 { source: '/posts/:slug', 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', 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.

When the Claude window runs out: my take on T3 Code
T3 Code addresses the frustration of hitting Claude’s five-hour usage limit during focused work. Rather than replacing coding agents, it provides a single desktop interface for tools such as Claude Code, Codex, Cursor, Grok Build, OpenCode, and Antigravity, with users signing in through their own accounts. Each thread remains tied to its original provider, so an unfinished Claude conversation cannot be transferred directly to Codex. However, the surrounding workspace, project, and workflow stay in one app. When a limit is reached, T3 Code identifies the restriction and reset time, allowing the user to start a new thread with another provider and quickly restate the task. The app is free and does not resell or mark up tokens, aligning its incentives with users who want to avoid unnecessary calls. Its MIT license, self-hosting support, public codebase, and more than 21,000 GitHub stars are especially important because it handles provider credentials and repository access. Although founder Theo frequently promotes the product, its transparency makes those claims easier to verify. The Linux desktop app remains young and occasional releases cause problems, but steady improvements and its ability to coordinate separate agents make it a practical solution for maintaining momentum.
Read post
Trying out autonomous agents
Sandcastle is Matt Pocock’s workflow for running Claude Code unattended inside a Docker container. It assigns the agent a GitHub issue in a git worktree, then merges the resulting commits back into the user’s branch. Its init command quickly scaffolds the infrastructure, but useful results still depend on supplying clear, focused tickets. Wayfinder fills that planning gap by breaking an idea that is too large for one session into a map issue and smaller decision tickets. Each ticket addresses a single unresolved question rather than a build segment, producing the self-contained, labeled issues Sandcastle needs. Without this map, vague issues led to equally vague pull requests. Before queueing work, the author uses a grilling skill to challenge assumptions and force difficult decisions. Resolving uncertainty early prevents the agent from making costly guesses during unattended runs. For secrets, the author replaces a plaintext .env file with Varlock and a committed .env.schema. Values are resolved through Doppler and exposed only through process.env during execution, although each secret must still be explicitly forwarded into the container configuration. The author would use the setup again but stresses that ticket quality remains decisive. Every diff is still reviewed, tasks stay tightly scoped, and fully unattended overnight operation remains a future goal.
Read post