Markdown Mirrors
Expose markdown versions of app pages so agents, crawlers, docs tools, and support workflows can read rendered content as text.
Every app page receives a markdown representation automatically:
page.tsxroutes are rendered on the server and converted into markdown.page.mdandpage.mdxroutes return their original source.- A
page.mdbesidepage.tsxoverrides the generated representation without replacing the React page.
No configuration is required. Use source-authored pages for static content such as about pages,
policies, changelogs, and content-heavy marketing pages. Keep page.tsx as the canonical page when
the visual route needs React, and add a sidecar only when its generated markdown needs a curated
replacement.
Representation precedence
| Files in a route folder | HTML page | Markdown representation |
|---|---|---|
page.tsx | Rendered React UI | Generated from HTML |
page.tsx and page.md | Rendered React UI | Exact page.md source |
page.md or page.mdx only | Rendered Markdown | Exact source |
Markdown app pages
---
title: About Farm
description: A markdown-first static page.
---
# About Farm
<Callout>Farm can render MDX from the app router.</Callout>This creates /about automatically. Because the route is source-authored markdown, Farm also serves the raw source at /about.md by default.
Override a React page
Keep the visual page in React:
export default function PricingPage() {
return <PricingCalculator />;
}Then add an optional agent-focused representation beside it:
# Pricing
Farm.js is free and open source.
## Plans
- Community: free
- Cloud: contact usThe browser still renders page.tsx at /pricing. Requests to /pricing.md, or requests to
/pricing with Accept: text/markdown, receive the exact contents of page.md.
Configure MDX components
export default defineConfig({
mdx: {
components: "./src/markdown-components.tsx",
markdownRoutes: true,
},
});import { Callout } from "./components/callout";
export const components = {
Callout,
};Set mdx.markdownRoutes to false when source-authored pages should render as HTML only.
Interactive components
A "use client" component the components module imports hydrates as an island in React apps, so
it works inside Markdown pages, including under a layout that hydrates as a whole route. The rest
of the page stays server HTML. Island props must be serializable, the same limit as other islands.
"use client";
import { useState } from "react";
export function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
<button onClick={() => navigator.clipboard.writeText(text).then(() => setCopied(true))}>
{copied ? "Copied" : "Copy"}
</button>
);
}Islands need mdx.components to be a module path. Components from a package that marks itself
"use client" render as static HTML on Markdown pages, and Farm logs which packages it left static.
Restrict exposed pages
Automatic mirrors include all application page routes. Restrict them when an application contains authenticated or private pages:
export default defineConfig({
md: {
expose: ["/", "/pricing", "/docs"],
cache: 60,
},
});Routes
| Page | Markdown mirror |
|---|---|
| / | /index.md |
| /pricing | /pricing.md |
| /docs | /docs.md |
Use cases
- AI assistants can fetch page content without parsing the full app shell.
- Pricing, docs, changelog, and policy pages become easy to cite.
- Teams can keep one source of truth: the actual rendered page.
Disable generated mirrors for every React page with md: false:
export default defineConfig({
md: false,
});Explicit page.md and page.mdx sources use mdx.markdownRoutes; set that option to false when
their raw routes must also be disabled.
llms.txt
Set agent: { llmsTxt: true } to publish /llms.txt, an index that points language models at these
mirrors, and /llms-full.txt, which inlines them. See
Agent readiness.
Per-route options
Routes can include a display title and cache override.
export default defineConfig({
md: {
expose: [
{
route: "/pricing",
title: "Pricing",
cache: 300,
},
"/docs/[...slug]",
],
includeMetadata: true,
},
});What gets returned
Markdown mirrors call the rendered page, strip scripts/styles and elements hidden by an attribute (aria-hidden="true", or hidden except hidden="until-found", which the browser can reveal), convert HTML headings, paragraphs, lists (ordered lists keep their numbers), blockquotes, tables (as GFM tables), and code blocks (keeping a language-* class or data-language as the fence language) into markdown, then return text/markdown. The mirror is built without CSS, so text hidden only by a stylesheet (display: none, a collapsed menu) still appears. Mark decorative text aria-hidden to keep it out of the mirror as well as out of screen readers.
Content without any hidden text (case-insensitive) skips hidden-element parsing. Any occurrence
still uses the same attribute-aware parser, including quoted values, revealable content, and forms.
curl http://localhost:3000/pricing.mdAgents can also request the normal page URL with explicit content negotiation:
curl -H "Accept: text/markdown" http://localhost:3000/pricingFarm returns the same markdown representation with Content-Type: text/markdown,
Content-Location: /pricing.md, and Vary: Accept. The HTML response also advertises the
.md URL through a Link header and a <link rel="alternate" type="text/markdown"> tag in the
document head, while ordinary browser requests continue to receive HTML. Set
metadata.alternates.types["text/markdown"] to point the head link somewhere else.
Converted mirrors preserve the page response's Set-Cookie headers, including on redirects, sign-in errors,
partial views, HEAD, and conditional 304 responses. A mirror that sets cookies uses
Cache-Control: private, no-store, even when md.cache is enabled, so a shared cache cannot replay
one reader's session cookies to another. Cookie-free mirrors keep their configured cache policy.
If the page returns a non-HTML response, such as a JSON error or a 429 rate limit, Farm returns
that response and its headers (including Retry-After) without dispatching the page again.
Bodyless 204, 205, and 304 responses also pass through, even if they declare an HTML content
type; they are not converted or filtered by partial-view parameters. Pass-through responses keep
the page's own content type and cache policy. HEAD keeps the status and
headers but omits the body. Sign-in redirects and non-HTML 401/403 responses continue to use
the Markdown explanations with the original status. Unused bodies from these explanations and
terminal HEAD responses are cancelled without delaying the response.
HTML 4xx and 5xx responses keep their original status and full converted error message.
Partial-view parameters (outline, section, max_tokens, cursor, and actions) do not
filter or replace errors, and a matching If-None-Match does not turn an error into 304.
Converted errors preserve WWW-Authenticate, Proxy-Authenticate, Retry-After, and Allow
when present. Generated sign-in and redirect explanations also preserve these headers,
including retry guidance. HTML-specific content lengths, encodings, and ETags are not copied
onto the converted body. Successful-page views keep their normal selection and validation behavior.
The internal HTML request omits If-Match, If-Unmodified-Since, If-None-Match,
If-Modified-Since, Range, and If-Range.
An HTML cache hit or byte fragment cannot be used to construct the Markdown representation.
Farm evaluates If-None-Match against the selected Markdown view's own ETag after conversion;
HTML ETags do not validate the mirror. Generated mirrors have no Last-Modified date and ignore
If-Modified-Since. Byte ranges are not supported: a range request receives the complete selected
view (or 304 for a matching Markdown ETag), not a slice of HTML. This does not change ordinary
HTML requests or the page's application-level middleware checks.
Mirrors and raw Markdown sources evaluate If-Match before If-None-Match, after page and
view validation. Their ETags are weak: If-Match: * succeeds for an existing successful
representation, but an entity-tag (even the current weak ETag or its strong-looking form)
cannot satisfy strong comparison. A failed match returns an empty 412 response with
Cache-Control: no-store; it does not replace page errors, redirects, or invalid-view errors.
Use If-None-Match for cache revalidation. If-Unmodified-Since is ignored because these
Markdown representations have no modification date; the HTML page's date is not reused.
The internal page request carries the caller's abort signal. In development, disconnecting the Markdown client also cancels the internal HTTP page fetch, including while its body is being read. Rendering and server-side fetches that observe their request's signal can stop cooperatively; cancellation does not undo work or side effects that have already completed. Normal response completion does not cancel the request.
Prerendered pages, such as those exporting dynamic = "force-static", are served as static files in
production, so they answer every request with HTML. They still send the Link header, and their
.md URL works, so agents should follow the header or request the .md URL directly.
Read part of a page
Agents can fetch less than the whole page. Every Markdown response, from a mirror or a page.md,
accepts these query parameters:
curl "http://localhost:3000/pricing.md?outline"
curl "http://localhost:3000/pricing.md?section=plans"
curl "http://localhost:3000/pricing.md?max_tokens=2000"?outlinereturns the heading tree. Each entry links to its section and estimates its size in tokens, so an agent can decide what to read.?section=<id>returns one heading and everything under it, up to the next heading at the same or a higher level. Ids follow GitHub's heading anchors (## Team plansisteam-plans; a repeated heading gets-1,-2). An unknown id answers404with the list of valid ids.?max_tokens=<n>returns the page, or the section with?section, in parts of aboutntokens (at least 100). See Read a long page in parts.
Headings inside fenced code blocks and front matter are ignored. Every Markdown response also
carries an ETag, so an agent can re-check a page with If-None-Match and receive 304 Not Modified when it has not changed.
Read a long page in parts
?max_tokens=<n> keeps each response within an agent's budget. A page that fits comes back whole.
A longer one comes back in parts, each cut where a reader would pause, before a heading or after a
paragraph, and ending with a link to the next part:
## Install
...
---
Continue: /guide.md?max_tokens=2000&cursor=48.k3j9x2a1 (about 3100 more tokens)The same link is in a Link: </guide.md?max_tokens=2000&cursor=48.k3j9x2a1>; rel="next" header,
and the last part has neither. A part never splits a code block unless the block alone is larger
than the budget; then the part closes the block and the next one reopens it with the same language,
so every part is valid Markdown. Token counts are estimates of about four characters per token.
Continuation, restart, and outline links keep the page's own query, so
/products.md?category=shoes&max_tokens=2000 keeps reading the shoes listing. The view parameters
(outline, section, actions, max_tokens, cursor) are never passed to the page itself.
The cursor names where to continue and which version of the page it belongs to. If the page changes
between requests, the next part answers 409 with a link to start again, rather than continuing
from a position that no longer lines up. A malformed cursor or a budget under 100 answers 400.
Describe a page's forms
The mirror turns a form's labels and buttons into text, but not what the form submits. When a page
has forms, its mirror lists one more line under Source::
Source: /contact
Actions: /contact.md?actions?actions answers with the page's forms as JSON, read from the same part of the page as the mirror
and with the same hidden-element rules:
curl "http://localhost:3000/contact.md?actions"{
"page": "/contact",
"markdown": "/contact.md",
"actions": [
{
"type": "form",
"method": "POST",
"action": "/api/contact",
"enctype": "application/x-www-form-urlencoded",
"fields": [{ "name": "email", "type": "email", "label": "Email", "required": true }],
"submit": [{ "label": "Send" }]
}
]
}actionis resolved the way a browser resolves it: an empty or missingactionsubmits to the page itself. Same-origin targets are root-relative; a form that only submits through JavaScript has noactionand is markedrequiresJavaScript.fieldscarry each control's name, type, label, preset value, and constraints (required,pattern,min,max,minLength,maxLength,accept). Radio buttons and checkboxes that share a name become one field withoptions, as do a select's options. Disabled controls are left out, since a browser would not submit them. Hidden fields keep their values, so a submission without JavaScript can be repeated; password and file inputs never include a value.submitlists the buttons, with a button's ownformactionorformmethodwhen it has one.- A React server action form, rendered with React's hidden
$ACTION_fields, includesserverAction.id. - A form that submits to an API route exposed as an MCP tool by
@farm.js/mcpincludestool: the toolnameand the MCPendpointthat serves it, so an agent can call the tool instead of posting the form. The match uses the form's method and resolved action against the running MCP server's tools. The caller'sauthorizepolicy still decides whether that tool is available to it.
A page.md source has no forms, so its ?actions view is an empty list. The view carries an
ETag like every other Markdown response. Like the mirror itself, it is rendered with the caller's
request, so it shows the forms that caller would see. Farm still renders the page and extracts its
forms, but skips converting its content to Markdown when only the actions JSON is needed. Error
responses still return their full Markdown explanation. When a mirror needs both the forms and
Markdown, they share the same prepared HTML content within that request; nothing is cached across
callers.
React Server Components apps
Apps built with defineConfig from @farm.js/plugin/rsc serve the same mirrors. Every page answers
at its .md URL and to Accept: text/markdown, with ?outline, ?section, ?max_tokens, and
?actions. HTML pages send the Link header, a missing .md page answers a Markdown 404, and reads
fire agent.read events. The mirror renders the page through the normal request path, so middleware
and sign-in gates apply, and a page that must not be shared keeps its private Cache-Control.
The RSC renderer currently sets a deployment cookie on HTML page renders. Its generated mirrors
therefore use private, no-store too; setting md.cache does not enable shared caching while that
cookie is present. This prevents caches from replaying response cookies to another reader.
Set the same md options on the RSC config:
import { defineConfig } from "@farm.js/plugin/rsc";
export default defineConfig({
md: { expose: ["/", "/pricing", "/docs/[...slug]"] },
});md: false turns mirrors off. page.md sources and llms.txt are not part of the RSC pipeline yet.
Production notes
- Restrict
md.exposewhen authenticated or private pages should not have generated mirrors. - Use
cachefor stable public pages. - Add
page.mdbesidepage.tsxwhen the generated representation needs a precise override. - Use
page.mdorpage.mdxalone when markdown is the page source of truth. - Use markdown mirrors for docs, pricing, policies, changelogs, release notes, and help center pages.