Prose pages, declared in the config.
docsPages weaves guides, tutorials and changelogs into the navigation, above the API reference, rendered in the browser like everything else. The feature page makes the case; this guide is the full syntax.
Three kinds of entry
docsPages is an ordered array, and array order is nav order. There is no position field. Entries are discriminated by their keys:
{
"docsPages": [
{ "slug": "getting-started", "title": "Getting started",
"url": "/docs-pages/getting-started.md", "home": true },
{ "group": "Guides", "collapsed": false, "pages": [
{ "slug": "pagination", "title": "Pagination", "url": "/docs-pages/pagination.md" },
{ "title": "Status page", "href": "https://status.example.com" }
]},
{ "title": "GitHub", "href": "https://github.com/acme/api" }
]
}- A page —
slugplus exactly one body source (see the next section). Optionallykind: "changelog", which opts it into the timeline treatment. - A group — one level, collapsible, with
collapsed: trueto start closed. Groups hold pages and external links, never other groups.idis optional; it serves multi-spec merge identity and defaults to the slugified title. - An external link —
hrefinstead of a body, rendered with an “external” icon and opened in a new tab.
An invalid entry — a page without slug or body, a group without pages, a link without href — is dropped with a console warning.
Two nav zones: docs above, appendix below
By default every entry sits above the API reference, under the “Documentation” heading. A top-level entry of any kind — page, group or external link — can declare nav: "bottom" and move to a trailing zone at the foot of the sidebar, below the whole reference and the webhooks.
{
"docsPages": [
{ "slug": "getting-started", "title": "Getting started", "url": "/docs/start.md" },
{ "slug": "support", "title": "Support", "url": "/docs/support.md", "nav": "bottom" },
{ "group": "Legal", "nav": "bottom", "pages": [ … ] },
{ "title": "Status", "href": "https://status.example.com", "nav": "bottom" }
]
}The trailing zone carries no heading of its own, only a separator, so it reads as an appendix rather than a second docs section.
nav is a top-level choice. Inside a group it is ignored, with a console warning, because a group travels whole.
The zone is an order as well as a place: the prev/next pager, the search listing and the exports all walk the top zone first, then the reference, then the bottom zone. A page declared first but sent to the bottom is the last one the pager reaches.
Where a page’s body comes from
Declare exactly one of three keys. If several slip in, what the page carries beats what it would have to fetch.
| Key | Body | Typical use |
|---|---|---|
url | A file to fetch — .md by default, .html and .txt recognized by extension | The default: files next to index.html |
content | The text itself, in the config | A backend that generates the config generates the prose with it |
contentId | The id of an element of the host page holding the text | Hand-authored prose behind a login, or a host that serves exactly one route |
The contentId vessel must be a script element with a non-executable type. A bare <script> would run its content as JavaScript before the app reads it.
<script type="text/markdown" id="doc-pagination">
# Pagination
Collection endpoints return one page at a time. Start with
[create a pet](apidoc:createPet).
</script>Two mechanics to know. A literal </script> inside a code sample closes the element early, so that page belongs in a file or in content. And the indentation shared by every line is removed, so the block can sit indented in your markup without markdown reading four spaces as a code block.
A contentId pointing at nothing fails like an unreachable file: a visible error naming the id, not a blank page.
Formats. A carried body has no extension, so format ("markdown", "html" or "text") states it when needed. It wins over the extension and over the element’s type. HTML pages pass through the same sanitizer as everything else and skip the markdown-only features. The sanitizer removes a <style> element, which would restyle the whole documentation, and form controls a reader could be asked to type into — their text is kept. An inline style attribute still works. A .txt renders as preformatted text, without a ToC but with its pager. A leading YAML frontmatter block in a Markdown file is stripped and ignored, so a README authored for another tool renders cleanly.
Or point at a manifest
docsPages may be a string URL instead of an array:
{ "docsPages": "/docs-pages/manifest.json" }The manifest is { "pages": [ … ] } with the same entries, and relative URLs inside it resolve against the manifest itself, so the docs folder can be versioned and moved independently of the host page. A manifest that fails to load or parse surfaces a visible nav-level error and leaves the reference nav intact.
Languages and the home takeover
title on every kind of entry, and every body field of a page, accept a per-language map — { "en": "/guide.en.md", "fr": "/guide.fr.md" } — resolved as the current UI language, then en, then the first declared key. Switching the language re-renders the open page and the nav labels. A plain string means “same in every language”.
home: true on at most one page makes it the landing view: #/ renders that page, and the technical welcome view (exports, pinned scenarios, stats) moves to #/overview with its own auto-generated nav entry. Declared twice, the first wins, with a console warning.
Writing pages that talk back
Markdown pages get these enrichments on top of GFM, each degrading gracefully in any other renderer. Three containers — cards, steps and tabs — have their own section, and so does {{var}} interpolation.
Callouts —
> [!NOTE],[!TIP],[!IMPORTANT],[!WARNING]and[!CAUTION]on the first line of a blockquote render as styled alerts. This is exactly the syntax GitHub renders.Code tabs — adjacent fenced blocks with no blank line between them become one tabbed block. The tab label is the fence’s meta string (
```js Node.js) or the language name. The reader’s language pick is remembered and applied to every tab group on every page. Blocks separated by a blank line stay independent, so the feature is opt-in by adjacency.Operation references — a link with the
apidoc:scheme renders inline with the operation’s method badge, addressed byoperationIdor by"METHOD /path":See [create a pet](apidoc:createPet) or [list pets](apidoc:GET /pets).Operation cards come from a fenced block whose info string is
apidoc:operation, one reference per line. Each renders method, path, summary and a deprecation badge when applicable, and the whole card links to the operation:```apidoc:operation GET /pets/{petId} createPet ```A reference that resolves to nothing renders visibly broken — struck through, with a tooltip — rather than as a dead link. Cards are links into the reference, not embedded consoles: running requests stays in the try-it panel. References address the active spec only; there are no cross-spec references.
Page references —
apidoc:page/{slug}links one docs page to another. It renders as an ordinary link, built through the router, so it keeps working under a multi-spec install where a hand-written#/page/…would lose the spec prefix. An unknown slug gets the same broken marker as an unknown operation:See [Pagination](apidoc:page/pagination).
Cards, steps and tabs
Three containers cover what MDX is usually brought in for — components in prose — without compiling anything and without shipping a framework. You write them like MDX. They are Markdown syntax, read by the app’s own parser, and a renderer that does not know them (GitHub, your editor) strips the tags and shows the prose inside.
<Cards>
<Card title="Quickstart" href="apidoc:page/getting-started">
Your first request, in two minutes.
</Card>
<Card title="Create a pet" href="apidoc:createPet">
The call most integrations start with.
</Card>
</Cards>
<Steps>
<Step title="Install">
```bash
npm i
```
</Step>
<Step title="Call">
Send your first request from the try-it.
</Step>
</Steps>
<Tabs>
<Tab label="Cloud">
Nothing to install.
</Tab>
<Tab label="Self-hosted">
Run the container next to your API.
</Tab>
</Tabs><Cards>renders a grid of links.<Card>needs atitleand anhref, which may be anapidoc:reference to an operation or a page.<Steps>renders a numbered list. A<Step>title is optional, and it is a label, not a heading: it stays out of the table of contents. Write a real###inside the step if you want it there.<Tabs>renders the same tablist as code tabs, keyboard included.<Tab>needs alabel. Prose tabs do not sync across the page and remember nothing — “Cloud or self-hosted” has nothing to follow elsewhere.
The rules, all of them. Tags are capitalized and exact. Attributes are name="value", double quotes only. A child’s body is full Markdown — fences, tables, callouts, {{var}} — and the blank lines around the tags are what let a plain renderer read it. Containers do not nest, and anything between children that is not a child is dropped. A malformed container is not recognized at all: the prose inside survives as ordinary text. Written inside a code fence, a container renders literally, which is how this page shows them.
What is deliberately not there: MDX itself, JSX expressions, import, and components of your own. Each would mean compiling and evaluating code in the reader’s browser, which the app never does.
Pages that read with the reader’s own values
Markdown pages resolve {{var}} from the same composition the try-it reads: the selected environment, with any host-supplied credentials underneath it. The page renders with the reader’s base URL and the reader’s tenant id, and the fence’s copy button copies the interpolated text.
There are three outcomes:
- A resolved, non-sensitive value renders as plain text — no chip, no highlight, so a snippet reads as a snippet.
- A sensitive value never renders. You get a masked chip with the name and
••••, and the value enters neither the DOM, nor the clipboard, nor the search index, nor any export. It is not redacted afterwards; it is never emitted. Every host-supplied credential is sensitive by definition, so those are covered too. - A missing value renders a warning chip carrying the name, on the same principle as the try-it’s send-blocking. The chip is a button that opens the environment manager. Under
environmentsLockedthe chip stays and the offer goes.
Copying a chip yields {{name}}, the template it stands for, rather than a bare variable name that would look resolved. An empty value counts as missing, exactly as in the try-it.
To write about a variable rather than resolve one, escape it with a backslash. In a fence or a code span, backslashes are literal, so type \{{name}}. In prose, markdown eats one, so type \\{{name}}.
Three surfaces stay uninterpolated:
.htmland.txtpages — interpolation is a Markdown enrichment, like callouts and code tabs.- The search index — it is built once from the source. Indexing values would make results depend on the selected environment, and would index secrets.
llms.txtandllms-full.txt— they are machine exports of the published documentation, not of one reader’s session.
Two details for authors. Headings interpolate too, but their anchors are computed from the source before the values land, so a deep link never depends on who is reading. And values are inserted as text, never as markup, so a base URL full of underscores cannot turn half your paragraph into emphasis.
Changelog pages
A page declaring kind: "changelog" renders as a release timeline: a dot in the left gutter per h2, with one continuous line joining them.
{ "slug": "changelog", "title": "Changelog",
"url": "/docs-pages/changelog.md", "kind": "changelog" }The convention is one h2 per release, with the date in the heading text — ## 1.2.0 — 2026-05-01. Nothing parses that date, so nothing has to agree on a locale, and the same file renders as an ordinary markdown changelog in GitHub or any other viewer.
The treatment rides the rendered content, so it works on .md and .html alike. A .txt page has no h2 to decorate and gets nothing. An unknown kind is dropped with a console warning, like an unknown format.
“Was this page helpful?”
Set feedback.url and every docs page grows a verdict row between the content and the pager:
{ "feedback": { "url": "https://example.com/docs-feedback" } }A click POSTs this, as JSON, to your endpoint. There is no other recipient, and no endpoint of ours anywhere in the product:
{ "page": "pagination", "verdict": "up" }verdict is "up" or "down". page is the entry’s slug, not its URL: the route is a page’s identity, while files and carried bodies move underneath it. Declare no feedback.url and there is no row at all — the default is null, and the app never sends anything anywhere on its own.
The row answers back. On success the buttons give way to a thank-you: one verdict per rendering, no vote counter, nothing stored. On failure it says so and stays clickable. Both outcomes are announced in the live region.
Two things to plan for. The POST carries a content-type: application/json header, which makes it a preflighted cross-origin request unless the endpoint is same-origin, so your server has to answer the OPTIONS too — the same negotiation the try-it console needs. And in multi-spec, feedback is overridable per spec: one API can gather verdicts while its neighbour gathers none, or each can post to its own collector.
Chrome, search, exports
Every page gets a table of contents from its h2/h3 headings, with the section being read highlighted as you scroll, plus prev/next paging in flattened, zone-ordered nav order.
Page content is indexed into the Cmd+K palette, lazily, on the first palette open, and a result deep-links to the matching heading.
The same prose flows into llms.txt — top-zone pages under their group titles as ## Guides, bottom-zone ones as ## Resources — and into llms-full.txt, with bodies inlined. See llms.txt & MCP.
In multi-spec, docs pages merge by identity: slug for pages, id for groups, href for links. A spec’s entry replaces the matching root entry in place, and unmatched entries append. Root-level pages shared by every spec are supported here, unlike scenarios. home resolves per spec, so one API can take over its landing view while its neighbour keeps the classic welcome.
The only thing the reader’s browser stores for all of this is the selected code-tab language.