---
title: "Markdown for agents"
url: "https://glokki.com/docs/markdown-for-agents"
locale: "en"
updated: "2026-10-14T21:07:00.361Z"
---

# Markdown for agents

Serve every published page as clean Markdown so LLM agents and crawlers get the content without the page chrome.

Serve every published page as clean Markdown so LLM agents and crawlers get the
content without the chrome: `https://your-site.com/about.md`, or `/about` with
`Accept: text/markdown`.

Everything runs in **your** app. The CMS is not involved: blocks decide how
they render to Markdown in `defineBlock()`, and the SDK walks the page tree,
resolves bound fields and serves the response.

## Checklist

| Step                                                       | Required | Section                                                                  |
| ---------------------------------------------------------- | -------- | ------------------------------------------------------------------------ |
| Upgrade to `@glokki-cms/sdk` 0.16.0 or later               | yes      | [Prerequisites](#prerequisites)                                          |
| Add `app/md/[[...slug]]/route.ts` and the `.md` rewrite    | yes      | [1. Mount the route](#1-mount-the-route)                                 |
| Pass `resolve` and `pageUrl` if your URLs carry a locale   | if i18n  | [Sites with locale prefixes](#sites-with-locale-prefixes)                |
| Serve Markdown on the page URL for `Accept: text/markdown` | optional | [Content negotiation](#content-negotiation)                              |
| Add `markdown` to the blocks the fallback renders poorly   | optional | [2. Teach blocks to render Markdown](#2-teach-blocks-to-render-markdown) |
| Check the output with `curl`                               | yes      | [Verify it](#verify-it)                                                  |

Nothing changes in the CMS: no setting, no deploy, no API key scope.

## Prerequisites

- `@glokki-cms/sdk` **0.16.0** or later. If you also use `@glokki-cms/react`
  or `@glokki-cms/cache`, upgrade them together (0.9.1 and 0.1.2 or later):
  they declare `^0.16.0` as their SDK range, and a `0.x` caret only matches
  the same minor version.
- A server runtime for the route. It fetches the page at request time, so it
  does not work with `output: "export"`.

## 1. Mount the route

```ts
// app/md/[[...slug]]/route.ts
import { createMarkdownRoute } from "@glokki-cms/sdk"

import { blockRegistry } from "@/blocks/registry"
import { cms } from "@/lib/cms"

export const { GET } = createMarkdownRoute({
  client: cms,
  blockRegistry,
  siteUrl: "https://your-site.com",
})
```

```ts
// next.config.ts
export default {
  async rewrites() {
    return [{ source: "/:path*.md", destination: "/md/:path*" }]
  },
}
```

The home page is `/index.md` (or `/md`).

The route handler imports your block registry, and with it your block
components. That is fine, because they are never rendered there. The registry
module must not run browser-only code (`window`, `document`) at import time,
though.

### Options

| Option             | Default                                              | Use it when                                                                                            |
| ------------------ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `resolve(request)` | strips `/md` and `.md` from the path                 | your URLs carry a locale (`/pl/o-nas.md` → `{ slug: "o-nas", locale: "pl" }`); return `null` for a 404 |
| `pageUrl(page)`    | `siteUrl + "/" + slug`                               | the canonical HTML URL differs from the slug (locale prefixes)                                         |
| `cacheControl`     | `public, s-maxage=300, stale-while-revalidate=86400` | you want a different CDN policy                                                                        |

Because the route reuses your `client`, its cache tags apply, so the
revalidation webhook refreshes the `.md` and the HTML together.

Pass a **published-content** client. A client created with `preview: true` and
a `previewToken` would serve draft pages, and the route caches its responses
publicly.

### Sites with locale prefixes

The default `resolve` turns the path into a slug and nothing else, so on a site
that prefixes non-default locales (`/pl/o-nas`), `/pl/o-nas.md` would look up
the slug `pl/o-nas` in the default locale and 404. Map the request through the
same function your HTML catch-all route uses, and build the canonical URL with
the prefix:

```ts
// lib/markdown.ts
import type { DeliveryPage } from "@glokki-cms/sdk"

import { defaultLocale, resolveRoute } from "./i18n"

export function resolveMarkdownRequest(request: Request) {
  const { pathname } = new URL(request.url)
  let path = pathname
  try {
    path = decodeURIComponent(pathname)
  } catch {
    // Malformed escape: keep the raw path; the route 404s on unsafe slugs.
  }
  const segments = path
    .replace(/^\/md(?=\/|$)/, "")
    .replace(/\.md$/, "")
    .split("/")
    .filter(Boolean)
  if (segments.at(-1) === "index") segments.pop() // /pl/index.md → pl home
  return resolveRoute(segments) // { locale, slug }; return null instead to 404
}

export function markdownPageUrl(siteUrl: string, page: DeliveryPage) {
  const base = siteUrl.replace(/\/+$/, "")
  const prefix = page.locale === defaultLocale ? "" : `/${page.locale}`
  const slug = page.slug.replace(/^\/+/, "")
  if (!slug) return prefix ? `${base}${prefix}` : `${base}/`
  return `${base}${prefix}/${slug}`
}
```

```ts
// app/md/[[...slug]]/route.ts
export const { GET } = createMarkdownRoute({
  client: cms,
  blockRegistry,
  siteUrl,
  resolve: resolveMarkdownRequest,
  pageUrl: (page) => markdownPageUrl(siteUrl, page),
})
```

The route still rejects unsafe slugs (`..`, `?`, `#`, `%`, `\`) after your
`resolve` runs, so a custom resolver doesn't need its own path checks.

### Content negotiation

Optional: serve Markdown on the page's own URL when an agent asks for it with
`Accept: text/markdown`. Merge this into your existing `proxy.ts`, **after**
redirect handling, so a moved page still redirects first:

```ts
// proxy.ts (Next 16)
import { type NextRequest, NextResponse } from "next/server"

/** Framework files, API/CDN routes, the Markdown route itself and files. */
const NON_PAGE_PATH_RE = /^\/(?:_next|api|cdn|md)(?:\/|$)|\.[^/]+$/

export function proxy(request: NextRequest) {
  // ...your redirect handling first

  const { pathname } = request.nextUrl
  if (
    !NON_PAGE_PATH_RE.test(pathname) &&
    request.headers.get("accept")?.includes("text/markdown")
  ) {
    const url = request.nextUrl.clone()
    url.pathname = `/md${pathname === "/" ? "" : pathname}`
    return NextResponse.rewrite(url)
  }

  return NextResponse.next()
}

// If you'd rather not filter in code, scope the proxy with a matcher instead:
// export const config = {
//   matcher: ["/((?!_next/|api/|cdn/|md(?:/|$)|.*\\.[^/]+$).*)"],
// }
```

Check your CDN before you turn this on. The page URL now serves two bodies,
and the Markdown one is cacheable (`public, s-maxage=300`). Its
`Vary: Accept` header is what stops a shared cache from handing it to a
browser. You can't add `Accept` to the HTML response's `Vary` yourself: Next
sets that header on App Router pages and overwrites values from `proxy.ts` or
`next.config` `headers()`. So a cache that honours `Vary` is safe, and at worst
gives an agent the cached HTML. A CDN that ignores `Vary` (Cloudflare does,
outside images) could serve the stored Markdown to browsers. Behind such a CDN,
skip negotiation and keep to the `.md` URLs, which never share a cache key with
HTML.

### What the route returns

- **200**: `text/markdown` with YAML frontmatter (`title`, `description`, `url`,
  `locale`, `updated`) and the page body. Headers: `X-Robots-Tag: noindex`
  (keeps search engines from indexing a duplicate of your HTML),
  `Link: <url>; rel="canonical"` and `Vary: Accept`.
- **404**: the page doesn't exist, or its SEO robots setting contains `noindex`.
- **503**: the CMS couldn't be reached. Sent with `Cache-Control: no-store` so
  a CDN never caches the failure.

## 2. Teach blocks to render Markdown

Add a `markdown` function to `defineBlock()`. It receives the block's props
(with bound fields such as `pageField("title")` already resolved) and a
`MarkdownContext`:

```ts
export const HeroBlock = defineBlock({
  type: "hero",
  // ...label, category, icon, propsSchema, defaultProps, component
  markdown: (props, { md }) =>
    [
      md.heading(1, props.heading),
      props.subheading && md.escape(props.subheading),
    ]
      .filter(Boolean)
      .join("\n\n"),
})

export const FaqBlock = defineBlock({
  type: "faq",
  // ...
  markdown: (props, { md }) =>
    props.items
      .map(
        (item) =>
          `${md.heading(3, item.question)}\n\n${md.richtext(item.answer)}`
      )
      .join("\n\n"),
})

export const CtaBlock = defineBlock({
  type: "cta",
  // ...
  markdown: (props, { md }) =>
    props.label ? md.link(props.label, props.href) : null,
})

export const CarouselBlock = defineBlock({
  type: "carousel",
  // ...
  markdown: false, // decorative: omit it (and its children)
})
```

`MarkdownContext`:

| Field      | What it holds                                                                                  |
| ---------- | ---------------------------------------------------------------------------------------------- |
| `slots`    | children already rendered to Markdown, keyed by slot name (`"default"` for unslotted children) |
| `children` | all children joined in tree order                                                              |
| `page`     | title, slug, locale, publishedAt, collectionData                                               |
| `md`       | `escape`, `richtext`, `link`, `image`, `list`, `heading` (from `createMarkdownHelpers`)        |

Rules:

- Return `null` or `""` to omit one instance. Return a string otherwise.
- `link`, `image` and `heading` take **plain text** and escape it for you.
  `list` takes items that are already Markdown. Escape any other prop text
  yourself with `md.escape`.
- A block with slots that defines `markdown` must place `ctx.slots` or
  `ctx.children` itself. Children it leaves out are not emitted.
- If the function throws, that block falls back (below) and the error is
  logged. The page still renders.
- `markdown` never leaves your app: it is not sent to the CMS.

## 3. Blocks without `markdown`

They still render. The fallback reads the block's fields in schema order:

| Field type                                              | Output                                                       |
| ------------------------------------------------------- | ------------------------------------------------------------ |
| `text`                                                  | paragraph                                                    |
| `textarea`                                              | paragraph with line breaks kept                              |
| `richtext`                                              | converted to Markdown (headings, lists, links, code, quotes) |
| `array`, `repeater`                                     | bullet list; each row joins its text fields with " — "       |
| everything else (`url`, `image`, `select`, `number`, …) | skipped                                                      |

Children are appended after the block's own text. So a block that keeps
important text in a `select` or `url` field needs a `markdown` function.

## 4. Testing a block's Markdown

```ts
import { createMarkdownHelpers, renderPageToMarkdown } from "@glokki-cms/sdk"

const md = renderPageToMarkdown({
  componentTree: [{ id: "1", type: "hero", props: { heading: "Hi" } }],
  blockRegistry,
  data: { title: "Home", slug: "/", locale: "en", publishedAt: null },
  siteUrl: "https://your-site.com",
})
expect(md).toBe("# Hi\n")
```

`createMarkdownHelpers(siteUrl)` gives you the same `md` object to unit-test a
hook directly.

## Verify it

```bash
curl -i http://localhost:4000/index.md            # home page
curl -i http://localhost:4000/about.md            # a page
curl -i http://localhost:4000/pl/o-nas.md         # a non-default locale
curl -i -H "Accept: text/markdown" http://localhost:4000/about  # negotiation
```

Check that:

- the response is `200` with `Content-Type: text/markdown; charset=utf-8`;
- the frontmatter `url` and the `Link: <...>; rel="canonical"` header point at
  the page's **HTML** URL, with the locale prefix when there is one;
- a page with a richtext block shows its headings, lists and links as Markdown;
- a block you marked `markdown: false` is absent.

## Troubleshooting

| Symptom                                                  | Cause and fix                                                                                                                                                                             |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/about.md` renders your HTML 404 page                   | The rewrite is missing or not applied. Add `rewrites()` to `next.config` and restart the dev server if it didn't pick up the config change.                                               |
| `/md/about` works but `/about.md` doesn't                | Same as above: the route is mounted, the rewrite isn't.                                                                                                                                   |
| Every page in a non-default locale is `404`              | The default `resolve` doesn't know your locale prefixes. See [Sites with locale prefixes](#sites-with-locale-prefixes).                                                                   |
| One page is `404` although it's published                | Its SEO robots setting contains `noindex`. That is deliberate: a page hidden from search engines is hidden from agents too.                                                               |
| `503 Service unavailable`                                | The CMS was unreachable or rejected the request. Check `CMS_BASE_URL` and `CMS_API_KEY` (a revoked key gives a 503 too); the server log has the error (`[markdown] failed to load page`). |
| `500 Internal error`                                     | Rendering failed outside a block hook (a hook that throws only falls back). The server log has `[markdown] failed to render page`.                                                        |
| A block's important text is missing                      | It lives in a field the fallback skips (`select`, `url`, …). Give the block a `markdown` function.                                                                                        |
| The route crashes at import with `window is not defined` | A block module touches the browser at import time. Move that code into an effect or the component body.                                                                                   |
| Browsers receive Markdown                                | A cache in front of the site ignores `Vary: Accept`. See [Content negotiation](#content-negotiation).                                                                                     |

## Not yet supported

`llms.txt` / `llms-full.txt`, redirects in the Markdown route, preview/draft
pages, layout header/footer, and A/B variants (the control variant is always
served).
