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 |
Add app/md/[[...slug]]/route.ts and the .md rewrite | yes | 1. Mount the route |
Pass resolve and pageUrl if your URLs carry a locale | if i18n | Sites with locale prefixes |
Serve Markdown on the page URL for Accept: text/markdown | optional | Content negotiation |
Add markdown to the blocks the fallback renders poorly | optional | 2. Teach blocks to render Markdown |
Check the output with curl | yes | Verify it |
Nothing changes in the CMS: no setting, no deploy, no API key scope.
Prerequisites
@glokki-cms/sdk0.16.0 or later. If you also use@glokki-cms/reactor@glokki-cms/cache, upgrade them together (0.9.1 and 0.1.2 or later): they declare^0.16.0as their SDK range, and a0.xcaret 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
// 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",
})
// 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:
// 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}`
}
// 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:
// 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/markdownwith 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"andVary: 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-storeso 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:
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
nullor""to omit one instance. Return a string otherwise. link,imageandheadingtake plain text and escape it for you.listtakes items that are already Markdown. Escape any other prop text yourself withmd.escape.- A block with slots that defines
markdownmust placectx.slotsorctx.childrenitself. 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.
markdownnever 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
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
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
200withContent-Type: text/markdown; charset=utf-8; - the frontmatter
urland theLink: <...>; 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: falseis 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. |
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. |
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).