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

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.

Securing My Blog's Secrets with Varlock and Doppler
The author describes the security risks of traditional .env files in Next.js projects: secrets stored in plain text on local machines, duplicated across environments, and easily exposed through accidental commits or misconfigured deployments. Varlock is introduced as a structured, schema-based replacement for dotenv, using a committed .env.schema file that defines variable types, requirements, and sensitivity without storing actual secrets. It integrates with Next.js as a drop-in replacement for @next/env, providing TypeScript types, startup validation, sensitivity-aware client bundling, and log redaction. Doppler is chosen as the secrets provider for its generous free tier, clean dashboard, environment configs, and especially its native Vercel sync, which keeps secrets updated without tokens or runtime API calls. Locally, Varlock’s exec() calls the Doppler CLI to fetch secrets in memory; in production, Vercel’s environment variables are used directly, with the same schema validating both paths. Benefits include zero secrets on disk, early configuration error detection, automatic sensitivity handling, and an AI-friendly schema. The author would skip Varlock’s Doppler plugin next time and rely solely on the CLI-based exec() approach despite a small startup performance cost.
Read post
I Fed Brewfather's API to Claude and Built My Own MCP Server
Brewfather is a comprehensive app for homebrewers that manages recipe design, batch tracking, fermentation logs, water chemistry, and ingredient inventory, making it a central hub for homebrew recipes. An MCP (Model Context Protocol) server acts like a plugin that exposes local tools and data to an AI such as Claude, letting it call a locally run server without any cloud intermediary or custom UI. Using Brewfather’s REST API, the author fed the documentation to Claude and quickly generated a local MCP server that connects Brewfather to Claude Code. This setup enables three key capabilities: browsing and inspecting real Brewfather recipes, checking the brew schedule and batch stages, and verifying whether the ingredient inventory is sufficient for an upcoming brew day. The main benefit is turning brewing data into a conversational interface, so users can simply ask questions like whether they are ready to brew on a given day. Future extensions could include logging fermentation readings, auto-creating batches from recipes, and sending notifications as batches progress through stages.
Read post