glokki
View .md

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

StepRequiredSection
Upgrade to @glokki-cms/sdk 0.16.0 or lateryesPrerequisites
Add app/md/[[...slug]]/route.ts and the .md rewriteyes1. Mount the route
Pass resolve and pageUrl if your URLs carry a localeif i18nSites with locale prefixes
Serve Markdown on the page URL for Accept: text/markdownoptionalContent negotiation
Add markdown to the blocks the fallback renders poorlyoptional2. Teach blocks to render Markdown
Check the output with curlyesVerify 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

// 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

OptionDefaultUse it when
resolve(request)strips /md and .md from the pathyour URLs carry a locale (/pl/o-nas.md → { slug: "o-nas", locale: "pl" }); return null for a 404
pageUrl(page)siteUrl + "/" + slugthe canonical HTML URL differs from the slug (locale prefixes)
cacheControlpublic, s-maxage=300, stale-while-revalidate=86400you 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/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:

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:

FieldWhat it holds
slotschildren already rendered to Markdown, keyed by slot name ("default" for unslotted children)
childrenall children joined in tree order
pagetitle, slug, locale, publishedAt, collectionData
mdescape, 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 typeOutput
textparagraph
textareaparagraph with line breaks kept
richtextconverted to Markdown (headings, lists, links, code, quotes)
array, repeaterbullet 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 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

SymptomCause and fix
/about.md renders your HTML 404 pageThe 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'tSame as above: the route is mounted, the rewrite isn't.
Every page in a non-default locale is 404The default resolve doesn't know your locale prefixes. See Sites with locale prefixes.
One page is 404 although it's publishedIts SEO robots setting contains noindex. That is deliberate: a page hidden from search engines is hidden from agents too.
503 Service unavailableThe 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 errorRendering 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 missingIt lives in a field the fallback skips (select, url, …). Give the block a markdown function.
The route crashes at import with window is not definedA block module touches the browser at import time. Move that code into an effect or the component body.
Browsers receive MarkdownA 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).