# Course: Markdown Routes with Next.js
https://www.sanity.io/learn/course/markdown-routes-with-nextjs

Build sites that serve both HTML for humans and markdown for AI agents. Learn to implement content negotiation using Next.js Route Handlers and rewrites, convert Portable Text to markdown, and create a discoverable sitemap.md for agent navigation.

---

## Navigation

## Contents

1. [Why your docs need markdown routes for AI and tools](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/why-markdown-routes) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/why-markdown-routes.md)
2. [Set up markdown-ready docs with Next.js and Sanity](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/project-setup) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/project-setup.md)
3. [Portable Text to Markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/portable-text-to-markdown) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/portable-text-to-markdown.md)
4. [Building the Markdown Route Handler](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/markdown-route-handlers) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/markdown-route-handlers.md)
5. [Content Negotiation with Rewrites](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/content-negotiation-with-rewrites) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/content-negotiation-with-rewrites.md)
6. [Section and sitemap routes for agent-friendly docs](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/section-and-sitemap-routes) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/section-and-sitemap-routes.md)
7. [The CopyMarkdown Component](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/copy-markdown-component) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/copy-markdown-component.md)
8. [Production-ready markdown routes](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/production-ready-markdown-routes) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/production-ready-markdown-routes.md)
9. [Serve markdown for AI agents with Next.js route handlers](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/markdown-routes-ai-agents-quiz-summary) · [markdown](https://www.sanity.io/learn/course/markdown-routes-with-nextjs/markdown-routes-ai-agents-quiz-summary.md)

---

## Lesson 1: Why your docs need markdown routes for AI and tools
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/why-markdown-routes

Learn why modern documentation should serve both HTML and markdown, how Accept header content negotiation compares to explicit markdown URLs, and why a dual-route strategy best supports AI agents, tools, and human readers.

Often we publish documentation-type content to the web: docs, knowledge bases, support articles, you name it. Like the very page you are reading now! This content might look great in the browser, but AI agents who increasingly might come by to pick up the information to help their users don’t really need this presentation.



In fact, serving agents a bunch of HTML might just bloat their context window.



So this course teaches you how to return content conditionally to user agents who are asking for markdown content in their request. Without having to duplicate your content.



## What you'll learn



### Key concepts recap



- AI agents can favor from content as **markdown** over HTML because it is plain text with lightweight structure (headings, lists, code blocks) and minimal noise, making it easier and more efficient for LLMs to parse and uses less tokens.

- The HTTP **`Accept` header** lets a client tell the server which content type it wants (e.g. `text/html` vs `text/markdown`), enabling content negotiation for the same URL. Agents has started to request this on the web.

- Implementing **both** Accept header negotiation **and** explicit markdown URLs provides flexibility: standards-based negotiation for capable agents, simple explicit routes for tools and testing, and clearer analytics.

- Using `Vary: Accept` allows CDNs to cache different representations per `Accept` header, but it can **reduce cache hit rates** and complicate caching behavior because the same URL now has multiple variants.


```bash:markdown-routing-examples.sh
# HTML by default (browser-like request)
curl https://www.sanity.io/learn/course/markdown-routes-with-nextjs

# Markdown via Accept header (content negotiation)
curl -H "Accept: text/markdown" https://www.sanity.io/learn/course/markdown-routes-with-nextjs

# Direct markdown URL (explicit route)
curl https://www.sanity.io/learn/course/markdown-routes-with-nextjs.md
```

> **Question:** Why do AI agents and tools benefit from dedicated markdown routes in addition to HTML docs?
>
> 1. Because markdown is plain text with lightweight structure, making it easier for LLMs and tools to parse than HTML, and explicit routes simplify access, analytics, and caching. **[correct]**
> 2. Because browsers cannot render HTML and require markdown instead.
> 3. Because the Accept header only works for image formats, not for documentation content.
> 4. Because markdown routes completely replace the need for HTML documentation for human users.

---

## Lesson 2: Set up markdown-ready docs with Next.js and Sanity
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/project-setup

Extend your existing Next.js + Sanity project with schemas, queries, and routing structure needed to export documentation content as markdown files alongside your HTML pages.

Let's extend your Next.js project with the structure we need for markdown export.



## What you'll learn



- Add `@portabletext/markdown` to your existing Next.js + Sanity project

- Create the schema: sections and pages

- Plan the Route Handler structure

- Understand how `next.config.ts` rewrites will route requests


## Prerequisites



This lesson assumes you have a working Next.js project with Sanity integration — similar to what you'd have after completing the "[Content-driven web application foundations](https://www.sanity.io/learn/course/content-driven-web-application-foundations)" course.



**Requirements:**



- Node.js 20+

- Next.js 16.x

- React 19+ (use `react@latest` if you encounter peer dependency warnings with Sanity packages)

- [ ] If you're starting fresh, create a new project:


```bash
npx create-next-app@latest keplar-docs --typescript --tailwind --app --src-dir
cd keplar-docs
```

## Install dependencies



- [ ] Install the Portable Text to Markdown library:


```bash
npm install @portabletext/markdown
```

- [ ] You should already have these from your Sanity setup, but verify they're installed:


```bash
npm install next-sanity @sanity/client @portabletext/react
```

## Create the schema



Our documentation site uses a simple two-level hierarchy: **Sections** contain **Articles**.



- [ ] Create the schema file:


```typescript:src/sanity/schema.ts
import { defineField, defineType } from 'sanity'

// Section schema - top-level groupings like "Getting Started", "API Reference"
export const section = defineType({
  name: 'section',
  title: 'Section',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      options: { source: 'title' },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'description',
      title: 'Description',
      type: 'text',
    }),
    defineField({
      name: 'order',
      title: 'Order',
      type: 'number',
      description: 'Display order in navigation',
    }),
  ],
})

// Article schema - individual documentation articles
export const article = defineType({
  name: 'article',
  title: 'Article',
  type: 'document',
  fields: [
    defineField({
      name: 'title',
      title: 'Title',
      type: 'string',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'slug',
      title: 'Slug',
      type: 'slug',
      options: { source: 'title' },
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'section',
      title: 'Section',
      type: 'reference',
      to: [{ type: 'section' }],
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'summary',
      title: 'Summary',
      type: 'text',
      description: 'Brief description for listings and SEO',
    }),
    defineField({
      name: 'content',
      title: 'Content',
      type: 'array',
      of: [
        { type: 'block' },
        { type: 'code' },
        { type: 'image' },
        { type: 'callout' },
      ],
    }),
    defineField({
      name: 'order',
      title: 'Order',
      type: 'number',
      description: 'Display order within section',
    }),
  ],
})

// Code block for documentation
export const codeBlock = defineType({
  name: 'code',
  title: 'Code Block',
  type: 'object',
  fields: [
    defineField({
      name: 'code',
      title: 'Code',
      type: 'text',
      validation: (Rule) => Rule.required(),
    }),
    defineField({
      name: 'language',
      title: 'Language',
      type: 'string',
      options: {
        list: [
          { title: 'JavaScript', value: 'javascript' },
          { title: 'TypeScript', value: 'typescript' },
          { title: 'Bash', value: 'bash' },
          { title: 'JSON', value: 'json' },
        ],
      },
    }),
    defineField({
      name: 'filename',
      title: 'Filename',
      type: 'string',
    }),
  ],
})

// Callout for tips, warnings, notes
export const callout = defineType({
  name: 'callout',
  title: 'Callout',
  type: 'object',
  fields: [
    defineField({
      name: 'type',
      title: 'Type',
      type: 'string',
      options: {
        list: [
          { title: 'Note', value: 'note' },
          { title: 'Tip', value: 'tip' },
          { title: 'Warning', value: 'warning' },
        ],
      },
      initialValue: 'note',
    }),
    defineField({
      name: 'content',
      title: 'Content',
      type: 'array',
      of: [{ type: 'block' }],
    }),
  ],
})

export const schemaTypes = [section, article, codeBlock, callout]
```

## Set up the Sanity Client



- [ ] Create the client configuration if you haven’t already:


```typescript:src/sanity/client.ts
import { createClient } from 'next-sanity'

export const client = createClient({
  projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
  dataset: process.env.NEXT_PUBLIC_SANITY_DATASET || 'production',
  apiVersion: '2024-01-01',
  useCdn: false,
})
```

- [ ] Add your environment variables:


```bash:.env.local
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```

> [!NOTE]
> The `NEXT_PUBLIC_SANITY_PROJECT_ID` and `NEXT_PUBLIC_SANITY_DATASET` variables are also used by the markdown serializers to build image CDN URLs. Make sure these match your Sanity project settings.



## Plan the route structure



Here's the file structure we'll build:



```
src/app/
├── docs/
│   └── [section]/
│       └── [article]/
│           └── page.tsx          # HTML pages (Server Components)
├── md/
│   └── [section]/
│       ├── route.ts              # Section markdown Route Handler
│       └── [article]/
│           └── route.ts          # Article markdown Route Handler
├── sitemap.md/
│   └── route.ts                  # Sitemap Route Handler
└── next.config.ts                # Rewrites for .md URLs and content negotiation
```

Key concepts:



1. **/docs/[section]/[article]/page.tsx** — Standard Next.js pages for HTML output

2. **/md/[section]/route.ts** — Route Handler for section markdown

3. **/md/[section]/[article]/route.ts** — Route Handler for article markdown

4. **/sitemap.md/route.ts** — Route Handler for the markdown sitemap

> [!NOTE]
> The `/md/` routes are internal — users access them via `.md` suffix URLs (like `/docs/getting-started/intro.md`) which are rewritten by `next.config.ts`.



### Route Handler vs Page



A **Page** (`page.tsx`) returns JSX that Next.js renders to HTML.



A **Route Handler** (`route.ts`) returns a raw `Response` object — you control the exact output. This is what we need for markdown.



```typescript
// Page - returns JSX
export default function Page() {
  return <div>Hello</div>
}

// Route Handler - returns Response
export function GET() {
  return new Response('# Hello', {
    headers: { 'Content-Type': 'text/markdown' }
  })
}
```

### Dynamic segments



`[section]` and `[article]` are **dynamic segments** that capture URL parameters:



- `/md/getting-started` → `{ section: 'getting-started' }`

- `/md/getting-started/quickstart` → `{ section: 'getting-started', article: 'quickstart' }`


We use separate Route Handlers for sections and articles rather than a catch-all, which makes the code clearer.



## Create the GROQ queries



We'll need queries to fetch content. 



- [ ] Create a queries file:


```typescript:src/sanity/queries.ts
import { defineQuery } from 'next-sanity'

// Get a single article by section and article slug
export const ARTICLE_QUERY = defineQuery(`
  *[_type == "article" && slug.current == $articleSlug && section->slug.current == $sectionSlug][0] {
    _id,
    title,
    slug,
    summary,
    content,
    "section": section-> {
      _id,
      title,
      slug
    }
  }
`)

// Get a section with its articles
export const SECTION_QUERY = defineQuery(`
  *[_type == "section" && slug.current == $sectionSlug][0] {
    _id,
    title,
    slug,
    description,
    "articles": *[_type == "article" && section._ref == ^._id] | order(order asc) {
      _id,
      title,
      slug,
      summary
    }
  }
`)

// Get all sections with their articles (for sitemap)
export const SITEMAP_QUERY = defineQuery(`
  *[_type == "section"] | order(order asc) {
    _id,
    title,
    slug,
    description,
    "articles": *[_type == "article" && section._ref == ^._id] | order(order asc) {
      _id,
      title,
      slug,
      summary
    }
  }
`)

// Get an article with navigation context (prev/next articles)
export const ARTICLE_WITH_NAV_QUERY = defineQuery(`
  {
    "article": *[_type == "article" && slug.current == $articleSlug && section->slug.current == $sectionSlug][0] {
      _id,
      title,
      slug,
      summary,
      content,
      "section": section-> {
        _id,
        title,
        slug
      }
    },
    "allArticles": *[_type == "article" && section->slug.current == $sectionSlug] | order(order asc) {
      _id,
      title,
      slug,
      order
    }
  }
`)
```

## Verify that it works



At this point you should be able to:



- Run `npm run dev` without errors

- See your schema types in Sanity Studio (if configured)

- Have the folder structure planned for routes


```bash
npm run dev
```

If you see the Next.js welcome page, you're ready for the next lesson.



## Next up



In the next lesson, we'll build the Portable Text to Markdown serializers — the core logic that converts Sanity content to markdown.



---

## Lesson 3: Portable Text to Markdown
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/portable-text-to-markdown

Sanity stores content as Portable Text — now we need to turn it into markdown.

## What you'll learn



- Understand Portable Text structure

- Use `@portabletext/markdown` for conversion

- Handle standard blocks and marks

- Create custom serializers for code blocks, images, and callouts


## Understanding Portable Text



[Portable Text](https://www.sanity.io/learn/docs/block-content) is an open source specification for block content and rich text format. Unlike HTML strings, it's structured data — an array of blocks that you can render and query however you want. 



Here's a simple example:



```json
[
  {
    "_type": "block",
    "style": "normal",
    "children": [
      {
        "_type": "span",
        "text": "Hello ",
        "marks": []
      },
      {
        "_type": "span",
        "text": "world",
        "marks": ["strong"]
      }
    ]
  }
]
```

This same content can render as:



- HTML: `<p>Hello <strong>world</strong></p>`

- Markdown: `Hello **world**`

- Plain text: `Hello world`


The `@portabletext/markdown` library handles this markdown conversion for us.



## Basic Conversion



- [ ] Import and use the converter:


```typescript
import { portableTextToMarkdown } from '@portabletext/markdown'
import type { PortableTextBlock } from '@portabletext/types'

const blocks: PortableTextBlock[] = [
  {
    _type: 'block',
    style: 'h2',
    children: [{ _type: 'span', text: 'Hello World' }],
  },
  {
    _type: 'block',
    style: 'normal',
    children: [{ _type: 'span', text: 'This is a paragraph.' }],
  },
]

const markdown = portableTextToMarkdown(blocks)
console.log(markdown)
// ## Hello World
//
// This is a paragraph.
```

The library automatically handles:



- Paragraphs → plain text

- Headings → `##`, `###`, `####`

- Bold → `**text**`

- Italic → `*text*`

- Links → `[text](url)`

- Lists → `-` or `1.`

- Blockquotes → `>`


## Custom type serializers



The schema includes custom block types that need custom serializers. 



- [ ] Create a file that handles code blocks, images, and callouts:


```typescript:src/lib/markdownSerializers.ts
import { portableTextToMarkdown } from '@portabletext/markdown'
import type {
  PortableTextBlock,
  PortableTextMarkDefinition,
} from '@portabletext/types'
import imageUrlBuilder from '@sanity/image-url'
import { client } from '@/sanity/lib/client'

const builder = imageUrlBuilder(client)

// Custom block types from your schema
type CodeBlock = {
  _type: 'code'
  language?: string
  filename?: string
  code: string
}

type ImageBlock = {
  _type: 'image'
  asset: { _ref: string }
  alt?: string
  caption?: string
}

type CalloutBlock = {
  _type: 'callout'
  style?: 'note' | 'tip' | 'important' | 'warning' | 'caution'
  content: PortableTextBlock[]
}

type CustomBlock = CodeBlock | ImageBlock | CalloutBlock

/**
 * Convert Portable Text to markdown with custom serializers
 */
export function convertToMarkdown(
  blocks: (PortableTextBlock | CustomBlock)[],
): string {
  return portableTextToMarkdown(blocks, {
    serializers: {
      types: {
        code: ({ value }: { value: CodeBlock }) => {
          const { language = '', filename, code } = value
          const lang = filename ? `${language}:${filename}` : language
          return `\`\`\`${lang}\n${code}\n\`\`\``
        },

        image: ({ value }: { value: ImageBlock }) => {
          const url = builder.image(value.asset).url()
          const alt = value.alt || ''
          const caption = value.caption
            ? `\n\n*${value.caption}*`
            : ''
          return `![${alt}](${url})${caption}`
        },

        callout: ({ value }: { value: CalloutBlock }) => {
          const style = value.style || 'note'
          const content = portableTextToMarkdown(value.content)
          // GitHub Flavored Markdown alerts
          const prefix = `[!${style.toUpperCase()}]`
          return `> ${prefix}\n> ${content.replace(/\n/g, '\n> ')}`
        },
      },
    },
  })
}
```

## Code block output



Code blocks render with language and optional filename:



```markdown
```typescript:src/lib/example.ts
export function hello() {
  return 'world'
}
```
```

## Callout output



Callouts use [GitHub Flavored Markdown](https://github.github.com/gfm/) alert syntax:



```markdown
> [!TIP]
> Use `pnpm` for faster installs.

> [!WARNING]
> This will delete all your data.

> [!NOTE]
> This is informational.
```

## URL normalization



Internal links need to be converted to absolute URLs since agents are bringing this content outside of your website.



- [ ] Add a custom mark serializer for internal links:


```typescript:src/lib/markdownSerializers.ts
/**
 * Normalize internal links to absolute URLs
 */
export function normalizeUrl(href: string, baseUrl: string): string {
  // Already absolute
  if (href.startsWith('http://') || href.startsWith('https://')) {
    return href
  }

  // Root-relative
  if (href.startsWith('/')) {
    return `${baseUrl}${href}`
  }

  // Relative path
  return `${baseUrl}/${href}`
}

// Add to convertToMarkdown options:
export function convertToMarkdown(
  blocks: (PortableTextBlock | CustomBlock)[],
  baseUrl: string = 'https://sanity.io',
): string {
  return portableTextToMarkdown(blocks, {
    serializers: {
      types: {
        // ... existing serializers
      },
      marks: {
        internalLink: ({ value, children }: any) => {
          const href = normalizeUrl(value.href, baseUrl)
          return `[${children}](${href})`
        },
      },
    },
  })
}
```

## Building the article markdown



- [ ] Create a helper that builds a complete markdown document with metadata:


```typescript:src/lib/buildArticleMarkdown.ts
import { convertToMarkdown } from './markdownSerializers'
import type { PortableTextBlock } from '@portabletext/types'

type ArticleData = {
  title: string
  slug: string
  description?: string
  content: PortableTextBlock[]
  section?: {
    title: string
    slug: string
  }
  prevArticle?: {
    title: string
    slug: string
  }
  nextArticle?: {
    title: string
    slug: string
  }
}

/**
 * Build complete article markdown with navigation context
 */
export function buildArticleMarkdown(
  article: ArticleData,
  baseUrl: string,
): string {
  const parts: string[] = []

  // Title
  parts.push(`# ${article.title}`)
  parts.push('')

  // Canonical URL
  parts.push(`**URL:** ${baseUrl}/${article.slug}`)
  parts.push('')

  // Navigation context
  if (article.section) {
    parts.push(`**Section:** [${article.section.title}](${baseUrl}/${article.section.slug})`)
  }

  const navLinks: string[] = []
  if (article.prevArticle) {
    navLinks.push(`← [${article.prevArticle.title}](${baseUrl}/${article.prevArticle.slug})`)
  }
  if (article.nextArticle) {
    navLinks.push(`[${article.nextArticle.title}](${baseUrl}/${article.nextArticle.slug}) →`)
  }
  if (navLinks.length > 0) {
    parts.push(`**Navigation:** ${navLinks.join(' | ')}`)
  }

  parts.push('')
  parts.push('---')
  parts.push('')

  // Summary
  if (article.description) {
    parts.push(`**Summary:** ${article.description}`)
    parts.push('')
  }

  // Content
  const content = convertToMarkdown(article.content, baseUrl)
  parts.push(content)

  return parts.join('\n')
}
```

## Section markdown



- [ ] For section listing pages, add this


```typescript:src/lib/buildSectionMarkdown.ts
type SectionData = {
  title: string
  slug: string
  description?: string
  articles: Array<{
    title: string
    slug: string
    description?: string
  }>
}

/**
 * Build section markdown with article list
 */
export function buildSectionMarkdown(
  section: SectionData,
  baseUrl: string,
): string {
  const parts: string[] = []

  // Section header
  parts.push(`# ${section.title}`)
  parts.push('')

  parts.push(`**URL:** ${baseUrl}/${section.slug}`)
  parts.push('')

  if (section.description) {
    parts.push(section.description)
    parts.push('')
  }

  parts.push('---')
  parts.push('')

  // Article list
  parts.push('## Articles in this section')
  parts.push('')

  for (const article of section.articles) {
    parts.push(`### [${article.title}](${baseUrl}/${article.slug})`)
    if (article.description) {
      parts.push('')
      parts.push(article.description)
    }
    parts.push('')
  }

  return parts.join('\n')
}
```

## Sitemap markdown



- [ ] Add this for the content discovery and navigation:


```typescript:src/lib/buildSitemapMarkdown.ts
type SitemapData = {
  categories: Array<{
    title: string
    slug: string
    sections: Array<{
      title: string
      slug: string
      articles: Array<{
        title: string
        slug: string
      }>
    }>
  }>
}

/**
 * Build sitemap markdown for content discovery
 */
export function buildSitemapMarkdown(
  sitemap: SitemapData,
  baseUrl: string,
): string {
  const parts: string[] = []

  parts.push('# Content Sitemap')
  parts.push('')
  parts.push('Complete navigation structure of all content.')
  parts.push('')
  parts.push('**Access any content as markdown:**')
  parts.push(`Add \`?format=markdown\` to any URL: \`${baseUrl}/[slug]?format=markdown\``)
  parts.push('')
  parts.push('---')
  parts.push('')

  for (const category of sitemap.categories) {
    parts.push(`## [${category.title}](${baseUrl}/${category.slug})`)
    parts.push('')

    for (const section of category.sections) {
      parts.push(`### [${section.title}](${baseUrl}/${section.slug})`)
      parts.push('')

      for (const article of section.articles) {
        parts.push(`- [${article.title}](${baseUrl}/${article.slug})`)
      }
      parts.push('')
    }
  }

  return parts.join('\n')
}
```

## Verify it works



- [ ] Add a test with sample blocks:


```typescript:src/lib/__tests__/markdown.test.ts
import { convertToMarkdown } from '../markdownSerializers'

const testBlocks = [
  {
    _type: 'block',
    style: 'h2',
    children: [{ _type: 'span', text: 'Getting Started' }],
  },
  {
    _type: 'block',
    style: 'normal',
    children: [{ _type: 'span', text: 'Install the package:' }],
  },
  {
    _type: 'code',
    language: 'bash',
    code: 'npm install @portabletext/markdown',
  },
  {
    _type: 'callout',
    style: 'tip',
    content: [
      {
        _type: 'block',
        style: 'normal',
        children: [
          { _type: 'span', text: 'Use ' },
          { _type: 'span', text: 'pnpm', marks: ['code'] },
          { _type: 'span', text: ' for faster installs.' },
        ],
      },
    ],
  },
]

const markdown = convertToMarkdown(testBlocks)
console.log(markdown)

// Expected output:
// ## Getting Started
//
// Install the package:
//
// ```bash
// npm install @portabletext/markdown
// ```
//
// > [!TIP]
// > Use `pnpm` for faster installs.
```

Verification checklist:



- Code blocks render with language and filename (`typescript:filename.ts`)

- Images have full CDN URLs from Sanity

- Callouts use GFM alert syntax (`> [!NOTE]`)

- Internal links are normalized to absolute URLs

- Article markdown includes title, URL, navigation, and summary

- Section markdown lists all articles with descriptions

- Sitemap markdown shows complete navigation structure


---

## Lesson 4: Building the Markdown Route Handler
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/markdown-route-handlers

Time to serve our first markdown — Route Handlers that return docs articles as markdown.

## What you'll learn



- Create Route Handlers for markdown responses

- Understand the `.md` suffix URL pattern

- Fetch content from Sanity and convert to markdown

- Return a Response with correct Content-Type header

- Handle 404s gracefully


## The URL Pattern



We're implementing the `.md` suffix pattern:



- `/docs/getting-started/quickstart` → HTML article

- `/docs/getting-started/quickstart.md` → Markdown

- `/docs/getting-started.md` → Section listing (markdown)


The `.md` suffix URLs are rewritten to internal `/md/` Route Handlers:



```text
/docs/section/article.md  →  /md/section/article  (rewrite)
/docs/section.md          →  /md/section          (rewrite)
```

## Creating the Route Handlers



Create the route directories:



```bash
mkdir -p src/app/md/[section]/[article]
mkdir -p src/app/md/[section]
```

### Article Route Handler



```typescript:src/app/md/[section]/[article]/route.ts
import { NextRequest } from 'next/server'
import { client } from '@/sanity/client'
import { ARTICLE_WITH_NAV_QUERY } from '@/sanity/queries'
import { buildArticleMarkdown } from '@/lib/markdownSerializers'

/**
 * Route Handler for markdown article responses.
 *
 * Internal route: /md/[section]/[article]
 * Accessed via: /docs/[section]/[article].md (rewrite)
 * Or via content negotiation: /docs/[section]/[article] with Accept: text/markdown
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ section: string; article: string }> }
) {
  const { section: sectionSlug, article: articleSlug } = await params
  
  try {
    const data = await client.fetch(ARTICLE_WITH_NAV_QUERY, {
      sectionSlug,
      articleSlug,
    })
  
    if (!data.article) {
      return new Response('Article not found', { status: 404 })
    }
  
    // Find prev/next articles for navigation
    const articleIndex = data.allArticles.findIndex(
      (a: { slug: { current: string } }) => a.slug.current === articleSlug
    )
    const prevArticle = articleIndex > 0 ? data.allArticles[articleIndex - 1] : undefined
    const nextArticle =
      articleIndex < data.allArticles.length - 1
        ? data.allArticles[articleIndex + 1]
        : undefined
  
    const canonicalUrl = `${process.env.NEXT_PUBLIC_SITE_URL || ''}/docs/${sectionSlug}/${articleSlug}`
  
    const markdown = buildArticleMarkdown(data.article, {
      prevArticle,
      nextArticle,
      canonicalUrl,
    })
  
    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
      },
    })
  } catch (error) {
    console.error('Markdown route error:', error)
    return new Response('Internal server error', { status: 500 })
  }
}
```

### Section Route Handler



```typescript:src/app/md/[section]/route.ts
import { NextRequest } from 'next/server'
import { client } from '@/sanity/client'
import { SECTION_QUERY } from '@/sanity/queries'
import { buildSectionMarkdown } from '@/lib/markdownSerializers'

/**
 * Route Handler for markdown section responses.
 *
 * Internal route: /md/[section]
 * Accessed via: /docs/[section].md (rewrite)
 * Or via content negotiation: /docs/[section] with Accept: text/markdown
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ section: string }> }
) {
  const { section: sectionSlug } = await params
  
  try {
    const section = await client.fetch(SECTION_QUERY, { sectionSlug })
  
    if (!section) {
      return new Response('Section not found', { status: 404 })
    }
  
    const markdown = buildSectionMarkdown(section)
  
    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
      },
    })
  } catch (error) {
    console.error('Section markdown route error:', error)
    return new Response('Internal server error', { status: 500 })
  }
}
```

## Understanding the Pattern



### Why `/md/` Instead of `.md` Folders?



Next.js doesn't support dynamic segments with file extensions like `[article].md`. So we use:



- `/md/[section]/[article]/route.ts` — internal Route Handler

- `next.config.ts` rewrites — map `.md` URLs to `/md/` routes


This keeps the public URLs clean (`/docs/section/article.md`) while using valid Next.js routing internally.



### Async Params



In Next.js 15+, route params are a Promise that must be awaited:



```typescript
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ section: string; article: string }> }
) {
  const { section, article } = await params
  // ...
}
```

### Returning a Response



Route Handlers return raw `Response` objects, not JSX:



```typescript
return new Response(markdown, {
  headers: {
    'Content-Type': 'text/markdown; charset=utf-8',
  },
})
```

The `Content-Type: text/markdown` header tells clients this is markdown, not HTML.



## The Navigation Query



The article handler uses a query that includes navigation context:



```typescript:src/sanity/queries.ts
export const ARTICLE_WITH_NAV_QUERY = groq`
  {
    "article": *[_type == "article" && slug.current == $articleSlug && section->slug.current == $sectionSlug][0] {
      _id,
      title,
      slug,
      summary,
      content,
      "section": section-> {
        _id,
        title,
        slug
      }
    },
    "allArticles": *[_type == "article" && section->slug.current == $sectionSlug] | order(order asc) {
      _id,
      title,
      slug,
      order
    }
  }
`
```

This fetches both the article and all sibling articles, so we can calculate prev/next links.



## Verify It Works



The Route Handlers exist, but they need rewrites to be accessible via `.md` URLs. We'll configure those in the next lesson.



For now, test the internal routes directly:



```bash
npm run dev
```

```bash
# Direct internal route access (will work after rewrites)
curl http://localhost:3000/md/getting-started/quickstart
```

- Route Handler files created in `/md/[section]/` and `/md/[section]/[article]/`

- Build passes (`npm run build`)

- Route Handlers appear in build output


---

## Lesson 5: Content Negotiation with Rewrites
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/content-negotiation-with-rewrites

Configure Next.js rewrites to serve markdown via .md suffix URLs and Accept header negotiation, keeping the same base URL for HTML and markdown.

## What you'll learn



- Configure `next.config.ts` rewrites for `.md` suffix URLs

- Add `Accept` header content negotiation

- Keep the same base URL for both HTML and markdown responses

- Test both access patterns with `curl`


## The Goal



You want the same `/docs` URL to serve three different ways:



1. Explicit `.md` URL → markdown

2. `Accept: text/markdown` header → markdown

3. Default (no suffix, no header) → HTML


This keeps your documentation URLs clean while supporting both human browsers and programmatic access.



## Configuring Rewrites



You need four rewrite rules in `next.config.ts`:



1. Two for explicit `.md` suffix URLs (section and article)

2. Two for `Accept` header negotiation (section and article)


```typescript:next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  rewrites: async () => ({
    beforeFiles: [
      // Explicit .md URL access (no header required)
      {
        source: "/docs/:section/:article.md",
        destination: "/md/:section/:article",
      },
      {
        source: "/docs/:section.md",
        destination: "/md/:section",
      },
      // Content negotiation (Accept header)
      {
        source: "/docs/:section/:article",
        destination: "/md/:section/:article",
        has: [
          {
            type: "header",
            key: "accept",
            value: "(.*)text/markdown(.*)",
          },
        ],
      },
      {
        source: "/docs/:section",
        destination: "/md/:section",
        has: [
          {
            type: "header",
            key: "accept",
            value: "(.*)text/markdown(.*)",
          },
        ],
      },
    ],
  }),
};

export default nextConfig;
```

## How It Works



### Explicit .md URLs



When someone requests `/docs/guides/setup.md`, Next.js rewrites it to `/md/guides/setup`. The browser URL stays `/docs/guides/setup.md`, but your route handler at `app/md/[section]/[article]/route.ts` serves the response.



### Accept Header Negotiation



When someone requests `/docs/guides/setup` with `Accept: text/markdown`, the `has` condition matches and Next.js rewrites to `/md/guides/setup`. Same route handler, same markdown response.



### HTML Default



When someone requests `/docs/guides/setup` without the `.md` suffix and without the `Accept: text/markdown` header, no rewrite happens. Next.js serves the normal page from `app/docs/[section]/[article]/page.tsx`.



## The has Condition



The `has` array checks the `Accept` header:



```typescript
has: [
  {
    type: "header",
    key: "accept",
    value: "(.*)text/markdown(.*)",
  },
]
```

The regex `(.*)text/markdown(.*)` matches any `Accept` header containing `text/markdown`, including:



- `Accept: text/markdown`

- `Accept: text/markdown, text/html`

- `Accept: */*, text/markdown`


## Why beforeFiles



The `beforeFiles` timing ensures rewrites happen before Next.js checks for matching pages. This means:



- Requests matching the rewrite rules go to `/md/...` routes

- Requests not matching fall through to `/docs/...` pages

- No conflicts between routes and rewrites


## Verify It Works



Test all three access patterns with `curl`:



### 1. Explicit .md URL



```bash
curl http://localhost:3000/docs/guides/setup.md
```

Should return markdown with `Content-Type: text/markdown`.



### 2. Accept Header



```bash
curl -H "Accept: text/markdown" http://localhost:3000/docs/guides/setup
```

Should return the same markdown.



### 3. Default HTML



```bash
curl http://localhost:3000/docs/guides/setup
```

Should return HTML with `Content-Type: text/html`.



## Common Issues



### Rewrites Not Working



If rewrites aren't triggering:



- Restart the dev server after changing `next.config.ts`

- Check that `rewrites` is an async function returning an object

- Verify the `source` pattern matches your URL structure


### Getting HTML Instead of Markdown



If you're getting HTML when you expect markdown:



- Check the `Accept` header is being sent correctly

- Verify the `has` condition regex matches your header value

- Ensure your `/md/...` route handler sets `Content-Type: text/markdown`


### 404 Errors



If you're getting 404s:



- Verify your `/md/...` route handlers exist

- Check that the `destination` path matches your route structure

- Ensure parameter names (`:section`, `:article`) match between source and destination


## Stega Markers



If you're using Sanity with visual editing, disable Stega markers in your markdown route handlers to avoid hidden characters in the output:



```typescript
const lesson = await client.fetch(
  query,
  params,
  {
    stega: false, // Disable visual editing markers
  }
);
```

This ensures clean markdown output without invisible editing metadata.



## Checklist



- Added four rewrite rules to `next.config.ts`

- Used `beforeFiles` timing

- Configured `has` conditions for `Accept` header

- Tested with `curl` for all three access patterns

- Verified `Content-Type` headers are correct

- Disabled Stega markers for clean markdown output


---

## Lesson 6: Section and sitemap routes for agent-friendly docs
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/section-and-sitemap-routes

Set up a dedicated /sitemap.md route and section listings so AI agents and users can quickly discover your documentation structure, access markdown versions of pages, and benefit from optimized queries and caching for navigation versus full article content.

# Section and Sitemap Routes



Individual articles are useful, but agents often want the full picture.



## What you'll learn



- Create a dedicated `/sitemap.md` route for content discovery

- Understand the two-tier content strategy

- Optimize queries for navigation vs full content


## Why a Sitemap?



When an AI agent first encounters your documentation, it needs to discover what's available. The sitemap is the entry point — a structured list of all sections and articles.



```text
/sitemap.md
```

This is different from an XML sitemap for SEO. It's a human-readable (and agent-readable) markdown document that shows the content structure.



## Creating the Sitemap Route



Create the route directory:



```bash
mkdir -p src/app/sitemap.md
```

Create the Route Handler:



```typescript:src/app/sitemap.md/route.ts
import { client } from '@/sanity/client'
import { SITEMAP_QUERY } from '@/sanity/queries'
import { buildSitemapMarkdown } from '@/lib/markdownSerializers'

/**
 * Sitemap route at /sitemap.md
 * Provides a markdown-formatted index of all documentation.
 */
export async function GET() {
  try {
    const sections = await client.fetch(SITEMAP_QUERY)
    const markdown = buildSitemapMarkdown(sections)

    return new Response(markdown, {
      headers: {
        'Content-Type': 'text/markdown; charset=utf-8',
        'Cache-Control': 'public, max-age=300, stale-while-revalidate=600',
      },
    })
  } catch (error) {
    console.error('Sitemap route error:', error)
    return new Response('Internal server error', { status: 500 })
  }
}
```

## Sitemap Output



The generated sitemap looks like:



```markdown
# Keplar Docs Sitemap

## How to Access Content as Markdown

- **Any page**: Add `.md` to the URL or use `Accept: text/markdown` header
- **Section listing**: `/docs/[section].md`
- **This sitemap**: `/sitemap.md`

---

## Getting Started

Get up and running with Keplar in minutes.

- [Quickstart](/docs/getting-started/quickstart) — Create your first API call
- [Installation](/docs/getting-started/installation) — Install the SDK
- [Authentication](/docs/getting-started/authentication) — Set up API keys

## API Reference

Complete API documentation.

- [Endpoints](/docs/api-reference/endpoints) — Available endpoints
- [Error Codes](/docs/api-reference/errors) — Error handling
```

This gives agents:



1. **Instructions** for accessing markdown

2. **Full structure** of all sections and articles

3. **Summaries** to understand what each article covers


## Two-Tier Content Strategy



We use different query strategies for different purposes:



### Navigation-Only (Sections, Sitemap)



Section listings and the sitemap don't include article content — just titles, slugs, and summaries:



```typescript
// SECTION_QUERY - no content field
{
  title,
  slug,
  description,
  "articles": *[...] {
    title,
    slug,
    summary   // No content!
  }
}
```

This keeps responses small. A section with 20 articles would be overwhelming if every article included full content.



### Full Content (Individual Articles)



Article routes include the full content:



```typescript
// ARTICLE_QUERY - includes content
{
  title,
  slug,
  summary,
  content,  // Full Portable Text
  "section": section-> { ... }
}
```

This is appropriate for single-article requests where the agent wants the complete content.



## Route Naming: `sitemap.md`



The folder is literally named `sitemap.md`:



```text
src/app/sitemap.md/
└── route.ts
```

This creates a route at `/sitemap.md`. The `.md` is part of the path, not a file extension. Next.js handles this correctly.



If you already have a `sitemap.ts` for XML sitemaps, they won't conflict. `/sitemap.xml` and `/sitemap.md` are different routes.



## Section Routes



Section routes use the `.md` suffix pattern, just like articles:



```text
/docs/getting-started.md  →  Section listing (markdown)
/docs/getting-started     →  Section page (HTML)
```

We created a dedicated Route Handler at `/md/[section]/route.ts` and configured rewrites to handle `.md` suffix requests.



## Verify It Works



Test the sitemap:



```bash
curl http://localhost:3000/sitemap.md
```

Expected output:



```markdown
# Keplar Docs Sitemap

## How to Access Content as Markdown

- **Any page**: Add `.md` to the URL or use `Accept: text/markdown` header
- **Section listing**: `/docs/[section].md`
- **This sitemap**: `/sitemap.md`

---

## Getting Started
...
```

Test a section listing with the `.md` suffix:



```bash
curl http://localhost:3000/docs/getting-started.md
```

Or using the Accept header:



```bash
curl -H "Accept: text/markdown" http://localhost:3000/docs/getting-started
```

Expected output:



```markdown
# Getting Started

Get up and running with Keplar in minutes.

## Articles

- [Quickstart](/docs/getting-started/quickstart)
  Create your first API call in under 5 minutes
- [Installation](/docs/getting-started/installation)
  Install the SDK for your platform
```

## Caching Strategy



Different routes get different cache durations:



- **Sitemap** — 5 min cache (structure changes rarely)

- **Section** — 5 min cache (article list changes rarely)

- **Article** — 1 min cache (content may update frequently)


```typescript
// Sitemap - longer cache
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600'

// Article - shorter cache
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300'
```

## Checklist



- `/sitemap.md` returns the full sitemap

- `/docs/[section].md` returns a section listing (articles only, no content)

- Accept header also works for sections: `Accept: text/markdown`

- Sitemap includes instructions for accessing markdown

- Cache headers are appropriate for each route type


---

## Lesson 7: The CopyMarkdown Component
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/copy-markdown-component

AI agents fetch markdown automatically, but humans need a button.

## What you'll learn



- Build a React component for copying markdown to clipboard

- Fetch the markdown URL and handle the response

- Provide visual feedback for loading, success, and error states

- Use progressive enhancement so the link still works without JavaScript


## Why a Copy Button?



Your markdown routes are great for AI agents that know to set the `Accept` header. But human users don't know this exists — they need a discoverable UI element.



A "Copy Markdown" button:



1. **Teaches users** the feature exists

2. **Provides quick access** without leaving the page

3. **Falls back gracefully** if clipboard access or the network fails


## Building the Component



Here's the complete `CopyMarkdown` component:



```typescript:components/CopyMarkdown.tsx
"use client";

import { useState } from "react";

type CopyState = "idle" | "loading" | "copied" | "error";

export function CopyMarkdown({
  path,
  label = "Copy Markdown",
}: {
  path: string;
  label?: string;
}) {
  const [state, setState] = useState<CopyState>("idle");
  const markdownUrl = `${path}.md`;

  const handleCopy = async (e: React.MouseEvent<HTMLAnchorElement>) => {
    e.preventDefault();
    setState("loading");

    try {
      const response = await fetch(markdownUrl);
      if (!response.ok) throw new Error("Failed to fetch markdown");

      const markdown = await response.text();
      await navigator.clipboard.writeText(markdown);

      setState("copied");
      setTimeout(() => setState("idle"), 2000);
    } catch (error) {
      console.error("Copy failed:", error);
      setState("error");
      window.open(markdownUrl, "_blank");
      setTimeout(() => setState("idle"), 2000);
    }
  };

  const stateConfig = {
    idle: { text: label, className: "text-gray-600 hover:text-gray-900" },
    loading: { text: "Loading...", className: "text-gray-600" },
    copied: {
      text: "Copied!",
      className: "text-green-600 bg-green-50 hover:bg-green-100",
    },
    error: {
      text: "Error (opened in tab)",
      className: "text-red-600 bg-red-50 hover:bg-red-100",
    },
  };

  const { text, className } = stateConfig[state];

  return (
    <a
      href={markdownUrl}
      onClick={handleCopy}
      className={`inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${className}`}
      target="_blank"
      rel="noopener noreferrer"
    >
      {text}
    </a>
  );
}
```

## Using the Component



Add the component to your docs article page:



```typescript:app/docs/[...slug]/page.tsx
import { CopyMarkdown } from "@/components/CopyMarkdown";

export default function ArticlePage({ params }: { params: { slug: string[] } }) {
  const path = `/docs/${params.slug.join("/")}`;

  return (
    <article>
      <header className="flex items-center justify-between">
        <h1>Article Title</h1>
        <CopyMarkdown path={path} />
      </header>
      {/* Article content */}
    </article>
  );
}
```

## Progressive Enhancement



The component is built as progressive enhancement:



1. The anchor's `href` points directly to the `.md` URL, so it works as a normal link

2. JavaScript enhances the link with `onClick` to intercept navigation and copy to clipboard

3. If JavaScript is disabled, the browser follows the link and opens the markdown in a new tab

4. If the clipboard write or fetch fails, the component falls back to `window.open(markdownUrl, '_blank')`


Example fallback behavior:



```typescript
catch (error) {
  console.error("Copy failed:", error);
  setState("error");
  // Fallback: open in new tab
  window.open(markdownUrl, "_blank");
  setTimeout(() => setState("idle"), 2000);
}
```

## Feedback States



The component cycles through four states:



- **idle** — Shows "Copy Markdown" in neutral gray

- **loading** — Shows "Loading..." while fetching markdown

- **copied** — Shows "Copied!" with green background when successful

- **error** — Shows "Error (opened in tab)" with red background when copy fails


The `copied` and `error` states automatically reset to `idle` after 2 seconds.



## Handling Stega Markers



If you're using Sanity's Visual Editing with Stega encoding, you may need to clean the markers from the markdown before copying:



```typescript
import { stegaClean } from "@sanity/client/stega";

const markdown = await response.text();
const cleanMarkdown = stegaClean(markdown);
await navigator.clipboard.writeText(cleanMarkdown);
```

This ensures users get clean markdown without invisible encoding markers.



## Styling Variations



The component uses Tailwind classes for styling. The design decisions:



- Neutral gray for idle and loading states (non-intrusive)

- Green background for success (clear positive feedback)

- Red background for errors (clear negative feedback)

- Rounded corners and padding for a button-like appearance

- Smooth transitions between states


Adjust the styling to match your design system.



## Verify It Works



Test the component:



- Click the button and verify markdown is copied to clipboard

- Check that the "Copied!" state appears briefly

- Paste the clipboard content to verify it's clean markdown


Test the fallback behavior:



- Disable JavaScript in your browser and verify the link still opens the markdown

- Test with an invalid path to verify the error state and fallback


## Next Up



For production, consider:



- Adding analytics to track how often users copy markdown

- Customizing the button label per page or section

- Adding keyboard shortcuts for power users

- Implementing rate limiting if you're concerned about abuse


---

## Lesson 8: Production-ready markdown routes
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/production-ready-markdown-routes

Configure caching, headers, analytics, performance, and error handling so your markdown routes run efficiently and reliably in production.

## Production Considerations



Your markdown routes work locally — now let's make them production-ready.



## What you'll learn



- Caching strategies for markdown routes

- CDN configuration and the `Vary: Accept` header

- Tracking markdown consumption in analytics

- Performance optimization for large documents


## Caching Strategy



Markdown routes should be cached aggressively — the content doesn't change frequently, and regenerating it on every request wastes compute.



### Cache-Control Headers



Set appropriate headers in your Route Handlers:



```typescript
return new Response(markdown, {
  headers: {
    'Content-Type': 'text/markdown; charset=utf-8',
    'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
  },
})
```

This means:



- **`public`** — CDNs can cache this

- **`max-age=60`** — Fresh for 60 seconds

- **`stale-while-revalidate=300`** — Serve stale content for 5 minutes while revalidating


### Different Routes, Different Durations



- **Article** — `max-age=60`, `stale-while-revalidate=300` (content may update)

- **Section** — `max-age=300`, `stale-while-revalidate=600` (structure changes less often)

- **Sitemap** — `max-age=300`, `stale-while-revalidate=600` (structure changes less often)


```typescript
// Sitemap - longer cache
'Cache-Control': 'public, max-age=300, stale-while-revalidate=600'

// Article - shorter cache
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300'
```

## The Vary Header Problem



When you use Accept header content negotiation, CDNs need to know that different Accept headers should get different cached responses.



The `Vary` header tells CDNs which request headers affect the response:



```typescript
return new Response(markdown, {
  headers: {
    'Content-Type': 'text/markdown; charset=utf-8',
    'Vary': 'Accept',  // Different Accept = different cache entry
  },
})
```

### The Trade-Off



`Vary: Accept` reduces cache efficiency:



- Without it: CDN caches one version per URL

- With it: CDN caches multiple versions per URL (one per Accept value)


In practice, this isn't a big problem:



- Most requests don't set Accept headers (browsers send `*/*`)

- AI agents consistently send `text/markdown`

- You end up with ~2 cached versions per URL


### Our Approach



Since we use explicit `/markdown/` routes AND Accept header negotiation:



- `/docs/*` URLs don't need `Vary: Accept` — the rewrite handles it

- `/markdown/*` URLs always return markdown — no variation needed


The rewrite approach avoids the Vary header complexity entirely.



## Analytics



You want to know how much your markdown routes are used. Separate tracking for `/markdown/*` requests is straightforward.



### Option 1: URL-Based Tracking



Your analytics tool already tracks page views. The `/markdown/*` URLs appear as separate entries:



```
/docs/getting-started/quickstart      →  1,234 views
/markdown/getting-started/quickstart  →    89 views
```

### Option 2: Custom Events



For more detail, log custom events in your Route Handler:



```typescript
export async function GET(request: NextRequest, { params }: ...) {
  // ... generate markdown ...

  // Log the request (fire and forget)
  logMarkdownRequest({
    path: request.nextUrl.pathname,
    userAgent: request.headers.get('user-agent'),
    acceptHeader: request.headers.get('accept'),
  }).catch(() => {})  // Don't fail the request if logging fails

  return new Response(markdown, { ... })
}
```

Track interesting properties:



- **User-Agent** — Identify which AI tools are accessing your docs

- **Accept header** — See if they used content negotiation or direct URLs

- **Path** — Which articles are most requested


## Performance Optimization



### Large Documents



If you have articles with extensive content (long guides, API references), consider:



1. **Pagination** — Split into multiple articles instead of one giant document

2. **Summary routes** — Offer navigation-only versions for discovery

3. **Streaming** — For very large responses, stream the markdown


Streaming example:



```typescript
export async function GET() {
  const sections = await client.fetch(SITEMAP_QUERY)

  // Create a readable stream
  const stream = new ReadableStream({
    start(controller) {
      controller.enqueue('# Sitemap\n\n')

      for (const section of sections) {
        controller.enqueue(`## ${section.title}\n\n`)
        // ... more content
      }

      controller.close()
    },
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
  })
}
```

In practice, streaming is overkill for most documentation. An article would need to be massive (100KB+) before streaming provides meaningful benefit.



### Query Optimization



Ensure your GROQ queries only fetch what's needed:



```typescript
// Good: Sitemap only needs navigation data
*[_type == "section"] {
  title,
  slug,
  "articles": *[_type == "article" && section._ref == ^._id] {
    title,
    slug,
    summary  // No content field!
  }
}

// Bad: Fetching content you don't need
*[_type == "section"] {
  ...,
  "articles": *[...] {
    ...,
    content  // Unnecessary for sitemap!
  }
}
```

## Error Handling



Production routes need robust error handling:



```typescript
export async function GET(request: NextRequest, { params }: ...) {
  try {
    // ... main logic ...
  } catch (error) {
    console.error('Markdown route error:', error)

    // Return a helpful error (but not too detailed in production)
    return new Response(
      process.env.NODE_ENV === 'development'
        ? `Error: ${error instanceof Error ? error.message : 'Unknown'}`
        : 'Internal server error',
      { status: 500 }
    )
  }
}
```

Log errors to your monitoring service (Sentry, LogRocket, etc.) for visibility.



## Deployment Checklist



Before deploying:



- [ ] Environment variables set (`NEXT_PUBLIC_SANITY_PROJECT_ID`, etc.)

- [ ] Cache-Control headers configured for each route

- [ ] Error handling in place

- [ ] Analytics tracking working (if desired)

- [ ] Stega markers disabled for markdown routes

- [ ] `/sitemap.md` returns expected content

- [ ] Content negotiation works (`curl -H "Accept: text/markdown"`)


### Test on Vercel Preview



Deploy a preview and test:



```bash
# Test sitemap
curl https://your-preview-url.vercel.app/sitemap.md

# Test content negotiation
curl -H "Accept: text/markdown" https://your-preview-url.vercel.app/docs/getting-started/quickstart

# Check headers
curl -I https://your-preview-url.vercel.app/markdown/getting-started/quickstart
```

## Next Up



You've built production-ready markdown routes. Let's verify your knowledge with a quick quiz.



**Continue to Lesson 9: Course Quiz →**



---

## Lesson 9: Serve markdown for AI agents with Next.js route handlers
https://www.sanity.io/learn/course/markdown-routes-with-nextjs/markdown-routes-ai-agents-quiz-summary

Review the key concepts for building markdown-first routes in Next.js that serve both browsers and AI agents from a single source of truth, including rewrites, optional catch-all routes, content negotiation, stega markers, and markdown sitemaps.

## Course quiz



Let's verify what stuck.



> **Question:** Why use `next.config.ts` rewrites instead of checking headers in the page component?
>
> 1. Rewrites are processed before the page component runs, avoiding unnecessary rendering **[correct]**
> 2. Rewrites are faster than reading headers
> 3. Page components can't access request headers
> 4. Rewrites work better with TypeScript

> **Question:** What does the `[[...slug]]` pattern do in Next.js Route Handlers?
>
> 1. Creates a required single parameter
> 2. Creates an optional catch-all that matches zero or more segments **[correct]**
> 3. Creates a required catch-all that must have at least one segment
> 4. Escapes special characters in the URL

> **Question:** Why disable Stega markers for markdown output?
>
> 1. They slow down the response
> 2. They add invisible Unicode characters that break markdown rendering **[correct]**
> 3. They're only needed for HTML
> 4. They increase the response size significantly

> **Question:** What Content-Type header should markdown routes return?
>
> 1. text/plain
> 2. text/html
> 3. text/markdown; charset=utf-8 **[correct]**
> 4. application/markdown

> **Question:** Why normalize internal links to absolute URLs in markdown?
>
> 1. Relative URLs don't work in markdown syntax
> 2. AI agents consuming markdown outside a browser can't resolve relative paths **[correct]**
> 3. Absolute URLs are better for SEO
> 4. It's required by the Portable Text specification

> **Question:** What's the purpose of the sitemap.md route?
>
> 1. SEO optimization for search engines
> 2. Providing a structured index for AI agents to discover all available content **[correct]**
> 3. Generating an XML sitemap
> 4. Tracking which articles have markdown versions

> **Question:** How does `Vary: Accept` affect CDN caching?
>
> 1. It disables caching entirely
> 2. It tells the CDN to cache multiple versions based on the Accept header **[correct]**
> 3. It improves cache hit rates
> 4. It only affects HTML responses

## Your score



Count your correct answers:



- **7/7** — Excellent! You've mastered markdown routes.

- **5-6** — Good understanding. Review the topics you missed.

- **3-4** — Solid start. Re-read the lessons for missed questions.

- **0-2** — Take another pass through the course material.


## What you've built



Congratulations! You now have a documentation site that serves:



- **HTML articles** for human users in browsers

- **Markdown responses** for AI agents via content negotiation

- **Explicit .md suffix URLs** for direct access

- **A /sitemap.md** for content discovery

- **A CopyMarkdown button** for humans who want markdown


This pattern makes your content accessible to both audiences from a single source of truth.



## Going further



Ideas for extending what you've built:



1. **Add search** — Include a search endpoint that returns markdown results

2. **Version tracking** — Serve different content versions based on API version

3. **PDF export** — Add a Route Handler that generates PDFs

4. **Agent analytics** — Track which AI tools are using your docs most


*Course complete! You're ready to make your content accessible to AI agents.*



---

## Related Resources

- [All courses and lessons](https://www.sanity.io/learn/sitemap.md)
- [Complete content for LLMs](https://www.sanity.io/learn/llms-full.txt)
