---
title: "Markdown Conversion"
description: "How HTML pages are converted to markdown for AI-friendly content delivery."
canonical_url: "https://nuxtseo.com/docs/ai-ready/guides/markdown"
last_updated: "2026-05-06T18:45:38.473Z"
---

[mdream](https://github.com/harlan-zw/mdream) converts HTML pages to markdown during prerendering and runtime.

## Build-Time

During `nuxi generate`:

1. Nuxt plugin queues `.md` route for each rendered page (`/about/` → `/about/index.md`)
2. Prerender middleware fetches HTML, converts to markdown, extracts metadata
3. `ai-ready:page:markdown` hook fires per page
4. Content appended to `page-data.jsonl` and streamed to `llms-full.txt`
5. Route serves raw markdown

Metadata extracted: title, description, headings, updatedAt (from `article:modified_time` etc).

## Runtime

Markdown served when:

- Path ends in `.md` (explicit), OR
- `Accept` includes `text/markdown` AND NOT `text/html` AND `sec-fetch-dest` ≠ `document`

Targets API clients (Claude Code, curl, [Bun](https://bun.sh)) while excluding browsers.

```bash
curl https://example.com/about.md
curl -H "Accept: text/markdown" https://example.com/about
```

When the `Accept` header negotiates markdown on a non-`.md` URL, the middleware
issues a `307` redirect to the `.md` twin so HTML and markdown
variants live under separate cache keys.

### Prerendered routes behind a CDN

`Accept` negotiation **does not work** on prerendered HTML routes
behind most CDNs (Cloudflare, Vercel Edge, Fastly on default plans). Edge caches
key by URL only and ignore `Vary` unless explicitly configured, so
the first response cached for `/foo` wins regardless of the requesting client's
`Accept` header.

Two ways agents can still find the markdown URL:

1. **Follow the alternate link**. Every prerendered HTML page includes
`<link rel="alternate" type="text/markdown" href="/foo.md">` in
the `<head>`, so HTML-parsing crawlers pick it up.
2. **Request .md directly**. The build step writes `/foo.md` for every
prerendered route and serves it as a static asset.

## Nuxt Content Integration

With [`@nuxt/content`](https://content.nuxt.com) v3 in your modules, the middleware serves source markdown directly for any route backed by a page collection, skipping the HTML→mdream round-trip.

```bash
npx nuxi@latest module add @nuxt/content
```

Define a page collection as you normally would:

```ts [content.config.ts]
import { defineCollection, defineContentConfig } from '@nuxt/content'

export default defineContentConfig({
  collections: {
    blog: defineCollection({
      type: 'page',
      source: 'blog/**/*.md',
    }),
  },
})
```

Now `/<route>.md` returns the source markdown, with `canonical_url` and `last_updated` merged into the frontmatter for [agent-readability](https://github.com/vercel-labs/agent-readability) compliance:

```bash
curl https://example.com/blog/hello-world.md
```

```md
---
title: "Hello World"
description: "A post served from @nuxt/content source markdown."
canonical_url: "https://example.com/blog/hello-world"
last_updated: "2026-04-25T03:43:31.143Z"
---

# Hello from Nuxt Content
...
```

Routes not backed by a content collection fall through to HTML→mdream conversion. The module auto-detects `@nuxt/content` at build time, no configuration required.

### Why source markdown?

`@nuxt/content` parses files into a structural AST ([minimark](https://github.com/farnabaz/minimark)). The lookup serializes that AST back to markdown via `minimark/stringify`, the same approach `@nuxt/content`'s built-in `/raw/<slug>.md` route uses. Compared to HTML→markdown conversion, the AST round-trip preserves semantic structure (lists, code fences, MDC components) without HTML parsing artifacts.

## Configuration

```ts [nuxt.config.ts]
export default defineNuxtConfig({
  aiReady: {
    mdreamOptions: {
      minimal: true,
    },
    markdownCacheHeaders: {
      maxAge: 3600,
      swr: true,
    },
  },
})
```

## Hooks

### `'ai-ready:mdreamConfig'`

Modify mdream options before conversion:

```ts [server/plugins/mdream-config.ts]
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('ai-ready:mdreamConfig', (options) => {
    if (options.origin?.includes('/blog/'))
      options.ignoreElements = [...(options.ignoreElements || []), '.author-bio']
  })
})
```

### `'ai-ready:page:markdown'`

Modify markdown output at runtime:

```ts [server/plugins/markdown-footer.ts]
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('ai-ready:page:markdown', (ctx) => {
    ctx.markdown = `---\ntitle: ${ctx.title}\n---\n\n${ctx.markdown}`
  })
})
```

See [Nitro Hooks](/docs/ai-ready/nitro-api/nitro-hooks) for context types.
