# Markline
> Open-source, self-hostable documentation framework with first-class OpenAPI, an interactive API playground, instant search, and AI on your own key (BYOK).
---
# Quickstart
Create and run a documentation site in under a minute.
## Install & scaffold
```bash
npm create @markline@latest my-docs
```
This creates a `markline.json`, sample MDX pages, an example OpenAPI spec, and
ready-to-use deploy configs.
```bash
cd my-docs
npm install
npm run dev
```
Already have a project? `npm install @markline/markline` then `npx markline init`.
## Project structure
Your repository holds only content and configuration:
```text
my-docs/
├── markline.json # navigation, theme, branding, API settings
├── docs/ # your pages (*.mdx)
│ ├── index.mdx
│ └── quickstart.mdx
└── api/
└── openapi.json # rendered as an interactive API reference
```
The OpenAPI file is optional — Markline works as a docs-only site too.
## Develop
Edit files under `docs/` and update `markline.json` to change navigation, branding,
and the API base URL. When you're ready, see [Deployment](/deployment).
---
# CLI
The markline command runs your docs project from its own markline.json and content.
## Commands
| Command | Description |
| --- | --- |
| `markline init [dir]` | Scaffold a new docs project. |
| `markline dev` | Start the dev server against `./markline.json`. |
| `markline build` | Build a production Node server bundle. |
| `markline start` | Serve a production build. |
| `markline export` | Build a static HTML site into `./out`. |
## How it works
The Markline package ships the rendering app; the CLI launches it pointed at the
content in your current directory. You never fork or edit the framework — you
upgrade it like any dependency.
```bash
npx markline dev
```
Live preview on http://localhost:3000.
```bash
npx markline export
```
Outputs `./out` for any static host.
```bash
npx markline build && npx markline start
```
A Node server with full features.
---
# Configuration
markline.json is the single source of truth for navigation, theming, and behavior.
## Theme & branding
The site name, used in the header and metadata.
`{ light, dark }` image paths, or `{ text }` for a wordmark.
`{ primary, primaryDark }` brand accents (hex). See [Theming](/theming) for the full palette and fonts.
Default color scheme.
## Topbar
Right-aligned header links, e.g. a GitHub link.
`{ label, href }` for the primary "Get started" button.
A small label pill next to the logo (e.g. `docs · v1`).
Topbar layout on docs + the API reference. `"full"` (default) spans edge-to-edge; `"contained"` centers it to the content max-width.
The homepage topbar, independent of `width`. Defaults to `"contained"` so the marketing nav aligns with the hero.
## Navigation
Navigation is a list of tabs, each with grouped pages. A tab can instead set
`openapi: true` to generate its sidebar from your OpenAPI spec.
```jsonc
"navigation": {
"tabs": [
{ "id": "docs", "label": "Documentation", "href": "/", "match": ["__default__"],
"groups": [{ "group": "Start", "pages": [{ "href": "/", "label": "Intro" }] }] },
{ "id": "api", "label": "API", "href": "/api-reference", "match": ["/api-reference"], "openapi": true }
]
}
```
## API & playground
Base URL the playground sends requests to (overrides the spec's servers).
The "Try it" experience. Default `"full"`.
How the playground reaches your API. `never` is required for static export.
See [API reference](/openapi) for the full guide — generating the reference, the live explorer, and per-resource/per-operation MDX overlays.
## Integrations
Base URL for "Edit this page" links; the page path is appended.
`plausible`, `googleAnalytics`, or `posthog`.
Force the "Was this helpful?" widgets on or off. Omitted → shown only when an
`endpoint` is set. See [Feedback](/feedback).
POST target for the "Was this helpful?" widgets (same JSON from docs pages and
API resource sections). Point it at a webhook, a Sheet, your own API, or the
bundled [`feedback-worker`](/feedback#worker). See [Feedback](/feedback) for the
payload and deploy steps.
## Versions & i18n
Documentation versions; the first is the default (unprefixed).
Localizations; the first locale is the default. Same mechanism as versions.
A project uses versions **or** locales — both share the URL-prefix mechanism. See [Versions & i18n](/versions) for the directory layout and worked examples.
---
# Theming
Brand your docs entirely from markline.json — logo, favicon, fonts, colors, and the full palette. No code to fork.
Everything visual is driven by the `theme` block (and a couple of `topbar`
fields) in your config — no component overrides required.
## The config file
Markline reads **`markline.json`**, falling back to **`docs.json`** so projects
migrating from a `docs.json` config work unchanged.
All theming lives under `theme` in that file. Image paths (`logo`,
`favicon`) are served from your project's `public/` directory.
## Default theme
The default color scheme. `"system"` follows the OS (the default); set
`"dark"` or `"light"` to force one. Visitors can still toggle, and their
choice is remembered.
```jsonc
"theme": { "appearance": "dark" } // open in dark mode by default
```
## Logo & favicon
The logo can be an **icon + wordmark** (the common docs pattern), light/dark
**images**, or just **text**.
Small mark shown next to the name (e.g. `/logo-mark.svg`).
Wordmark text shown beside the icon (defaults to `name`).
Full logo images, swapped by color scheme. Used when no `icon` is set.
Favicon path, e.g. `/favicon.svg`.
```jsonc
"theme": {
"logo": { "icon": "/logo-mark.svg", "text": "acme" },
"favicon": "/favicon.svg"
}
```
## Accent color
Brand accent (hex) for light mode.
Brand accent for dark mode (and hover).
```jsonc
"theme": { "colors": { "primary": "#4f46e5", "primaryDark": "#818cf8" } }
```
Using a **light** accent (e.g. lime or yellow)? White text on it is invisible.
Set the on-accent text color with the `--c-on-brand` variable (see below) — it
controls text/icons on brand-colored surfaces (buttons, badges, numbered
cards).
## Full palette
For deeper theming, override any CSS custom property via
`theme.cssVariables`, split into `light` and `dark`. **Color values are
space-separated RGB triples** (e.g. `247 246 241`), not hex.
Always provide both `light` and `dark` if you customize the palette —
otherwise dark mode falls back to Markline's default dark.
The tokens you'll reach for most:
| Token | Meaning |
| --- | --- |
| `--c-brand`, `--c-brand-deep` | Accent + hover (also set via `colors`) |
| `--c-on-brand` | Text/icon color on brand surfaces |
| `--c-paper`, `--c-paper-2` | Page and raised surface backgrounds |
| `--c-ink`, `--c-ink-soft`, `--c-ink-2`, `--c-ink-3` | Text shades |
| `--c-slate-0` … `--c-slate-8` | Neutral scale (borders, muted text) |
```jsonc
"theme": {
"cssVariables": {
"light": {
"--c-paper": "247 246 241",
"--c-ink": "15 15 14",
"--c-on-brand": "15 15 14"
},
"dark": {
"--c-paper": "18 18 14",
"--c-ink": "232 231 224",
"--c-on-brand": "15 15 14"
}
}
}
```
## Fonts
Font-family overrides. The font must be system-available or self-loaded (e.g.
via a `@font-face` in a stylesheet you include). Defaults to Geist.
```jsonc
"theme": { "font": { "sans": "Inter", "mono": "JetBrains Mono" } }
```
## Label pill
A small bordered pill shown next to the logo — handy for a version or status
label, e.g. `"docs · v1"` or `"beta"`.
## Worked example
A complete warm, lime-accented theme that defaults to dark:
```jsonc
{
"name": "acme",
"theme": {
"appearance": "dark",
"logo": { "icon": "/mark.svg", "text": "acme" },
"favicon": "/mark.svg",
"colors": { "primary": "#D7FC50", "primaryDark": "#C2E63D" },
"cssVariables": {
"light": { "--c-on-brand": "15 15 14", "--c-paper": "247 246 241", "--c-ink": "15 15 14" },
"dark": { "--c-on-brand": "15 15 14", "--c-paper": "18 18 14", "--c-ink": "232 231 224" }
}
},
"topbar": { "badge": "docs · v1" }
}
```
Changing `theme` values takes effect on the next `markline dev` reload —
no rebuild of the framework, ever.
---
# Components
Rich components available in any MDX page — no import required.
## Callouts
A neutral note.
A helpful suggestion.
Something to watch out for.
A destructive or irreversible action.
A confirmation of success.
## Cards
Link cards group neatly.
Use them for navigation.
## Steps
Describe the first action.
Describe the next one.
## Tabs & accordions
First tab content.
Second tab content.
Yes — any MDX works inside these components.
Yes, including the interactive ones.
## Param fields
The unique identifier.
Maximum number of results.
---
# API reference
Point Markline at an OpenAPI document and it generates a polished three-pane reference — resource pages, multi-language code, and a live request explorer.
Markline turns an OpenAPI 3.0/3.1 document into a full reference: a left
resource nav, per-resource pages with attribute and parameter tables, dark
code rails (cURL / Node / Python / Go), and an inline **request explorer**.
## Add a spec
Put it at `api/openapi.json` in your content root. A remote URL works too —
snapshot it into that file at build time so your docs never depend on a
live service.
```
content/
├── markline.json
└── api/
└── openapi.json
```
Give a navigation tab `openapi: true` — its sidebar is generated from the
spec's tags and operations.
```jsonc
"navigation": {
"tabs": [
{ "id": "docs", "label": "Documentation", "href": "/", "match": ["__default__"], "groups": [] },
{ "id": "api", "label": "API reference", "href": "/api-reference", "match": ["/api-reference"], "openapi": true }
]
}
```
Point the explorer at your API. `api.baseUrl` overrides the spec's `servers`.
```jsonc
"api": { "baseUrl": "https://api.example.com", "playground": { "mode": "full", "proxy": "auto" } }
```
## What you get
The reference is **per-resource**: one page per tag, opening on the first
resource. Each page has:
- A **resource summary** + an Endpoints card listing the tag's operations.
- A **"The object" section** — attributes derived from the schema, with types,
required/optional flags, enums, and a sample JSON.
- One **section per endpoint** — method + path, parameters (path / query / body)
from the schema, and a dark code rail with **cURL, Node, Python and Go**
samples generated from the operation, plus the example response.
Resource pages live at `/api-reference/`; the per-operation pages
remain reachable too (deep links, search). Navigation between docs and the
reference is instant — it's one app, not a separate site.
## Nested tags
Slash-separated tags — the convention NestJS emits from `@ApiTags('store/orders')`
— become a **nested sidebar**. No config: Markline groups by shared prefix and
title-cases each segment (with an acronym map, so `fx` → `FX`, `api` → `API`).
```
store/products Store
store/orders → ├── Products
store/refunds ├── Orders
admin/users └── Refunds
admin/api/keys Admin
├── Users
└── API
└── Keys
```
- **Parent groups are nav-only** accordions (expand/collapse), opening to the
active resource. Each **leaf** tag stays a normal resource page.
- **URLs are unchanged** — the slug is still the whole tag, so `store/orders`
lives at `/api-reference/store-orders` and existing links keep working.
- Depth is arbitrary (`admin/api/keys` nests three levels); the page title
shows the leaf (`Keys`) with an `Admin / API` breadcrumb.
- Single-segment tags (`payments`) are unchanged — plain top-level entries.
This is automatic for any spec using slash tags — you don't flatten your
`@ApiTags` to get clean resource boundaries. Per-resource
[MDX overlays](#overlays) still key off the full slug (`store-orders.mdx`).
## Sidebar order
Within a resource, operations render in the order Markline reads them from the
document. That's rarely the order you'd teach in — the "create a session"
call is usually declared after the read endpoints, and it's the one that should
open the page.
Set `x-nav-order` (a number) on any operation to pin it:
```jsonc
"paths": {
"/session": {
"post": {
"operationId": "createSession",
"summary": "Create a session",
"x-nav-order": 1 // opens the resource
}
},
"/session/{id}": {
"delete": {
"operationId": "endSession",
"summary": "End a session",
"x-nav-order": 2
}
}
}
```
- Operations carrying `x-nav-order` sort **ascending**, ahead of everything else.
- Operations without it keep document order, after the ordered ones.
- You annotate only what you care about — a spec with no `x-nav-order` anywhere
renders exactly as it did before.
- Numbers are relative, not positional. Use `10, 20, 30` and you can insert
later without renumbering. Ties fall back to document order.
- The order applies to all three surfaces that read the resource at once: the
sidebar tree, the **Endpoints** card, and the rendered section order.
This orders operations **within** a tag. Resource order comes from the
root `tags` array — list your tags there in the order you want them, and any
tag you leave out sorts after the listed ones, alphabetically.
### Why not just reorder the document?
Two reasons the document's own order can't carry this:
- **Methods on one path are fixed.** Markline reads verbs in a stable order —
`get`, `post`, `put`, `patch`, `delete`, `options`, `head` — so two operations
sharing a path item always come out GET before POST. Moving them around in the
document changes nothing. If you want **Create account** (`POST /accounts`)
above **List accounts** (`GET /accounts`), `x-nav-order` is the only lever.
- **Generated documents get regenerated.** If your spec comes out of NestJS,
FastAPI, springdoc or similar, path order follows your route or handler
declaration order. Hand-sorting the emitted JSON is wiped on the next build,
and reordering handlers to fix the docs couples source layout to reading
order. An annotation on the handler survives regeneration.
See the [framework guides](/guides) for the per-framework annotation.
## Code samples
Every endpoint gets a dark **code rail**. By default Markline *generates* four
samples — cURL, Node, Python, Go — from the operation itself: the Node/Python/Go
ones are SDK-style (`client..(…)`), inferred from the tag,
`operationId`, and the body / path / query parameters. You shape the rail two
ways.
### Pick the generated languages
`api.codeSamples` chooses **which generated languages appear, and in what order**.
It's a subset of `"curl" | "node" | "python" | "go"`; omit it and all four show.
```jsonc
"api": {
"baseUrl": "https://api.example.com",
"codeSamples": ["curl", "node"] // just cURL + Node, in that order
}
```
If you don't ship an SDK, the generated Node/Python/Go snippets reference
a client that doesn't exist. Set `"codeSamples": ["curl"]` to show only cURL —
or author your own per-operation samples with `x-codeSamples` below.
### How generated samples are named
There's no separate "SDK name" setting — the generated snippets build their
identifiers from your spec. A sample like `acme.payments.create({ … })` is
assembled from four derived tokens:
| Token | Derived from | Example |
| --- | --- | --- |
| **client** | first word of `info.title`, lowercased, alphanumerics only (else `client`) | `"Acme Payments API"` → `acme` |
| **resource** | the operation's **tag**, lowercased and stripped | tag `Payment Methods` → `paymentmethods` |
| **method** | HTTP verb + path shape | `POST`→`create`, `PUT`/`PATCH`→`update`, `DELETE`→`del`, `GET` with `{id}`→`retrieve`, `GET` without→`list` |
| **result** | singularized tag | `projects` → `project` |
So the two levers on the generated rails are **`info.title`** (the client name)
and **your tag names** (the resource accessors). To match a real SDK exactly —
custom client name, hand-written method signatures, a language Markline doesn't
generate — reach for `x-codeSamples`.
### Bring your own with `x-codeSamples`
To replace the generated rail on a specific operation, add the `x-codeSamples`
extension to that operation **in your OpenAPI document** (not in `markline.json`).
The older `x-code-samples` spelling is accepted too — Markline reads either, so
specs written for Redoc/Stripe work unchanged.
```jsonc
"paths": {
"/v1/payments": {
"post": {
"operationId": "createPayment",
"summary": "Create a payment",
"x-codeSamples": [
{
"lang": "ruby",
"label": "Ruby",
"source": "Acme::Payment.create(\n amount: 1000,\n currency: \"usd\",\n)"
},
{
"lang": "php",
"source": "$client->payments->create([\n 'amount' => 1000,\n 'currency' => 'usd',\n]);"
}
]
}
}
}
```
Each entry has three fields:
The code shown in the tab. Rendered **verbatim** — HTML-escaped, with no
re-highlighting — so format it exactly as you want it to read, newlines (`\n`)
and indentation included.
The tab label. Falls back to `lang`, then to `"Sample"`.
Free-form language hint (`ruby`, `php`, `java`, `kotlin`, …) — **not** limited
to the four generated languages. Used for the tab's internal key and as the
label fallback.
`x-codeSamples` is **all-or-nothing per operation**: as soon as an
operation carries one custom sample, the entire generated rail for that
operation is replaced, and `api.codeSamples` no longer applies to it. List
*every* language you want shown — tabs appear in array order. Other operations
keep their generated rails untouched.
This is the escape hatch for hand-written, copy-paste-correct examples: real SDK
calls, framework-specific snippets, or languages Markline can't generate.
## The playground
Write endpoints get an inline **request explorer**: an editable
`Authorization` field, the request fields, a **Send** button, and the live
response with status + latency. It's the same engine on every surface.
`full` — inline inputs + the request console + the API Explorer modal.
`inline` — inline inputs + the request console, no modal.
`explorer` — read-only docs + console + the API Explorer modal.
`off` — a static cURL panel, no interactivity.
How requests reach your API. `auto` (default) tries a direct browser fetch
and falls back to the built-in server proxy on CORS failure; `always` forces
the proxy; `never` is direct-only — **required for static export**.
The server proxy (`/api/playground`) only runs on a Node/Vercel
deployment and is dropped in static export. On a static host, set
`proxy: "never"` and enable CORS on your API, or the explorer can't reach it.
## Events & webhooks
If your API emits webhooks or async events, Markline documents them alongside
the endpoints that trigger them. Each resource grows an **Events** tab listing
the events it can deliver — name, summary, payload schema, and a sample — and
the two are cross-linked: an emitting endpoint shows a **Triggers** chip, and
the event shows an **Emitted by** back-link to that endpoint. The tab only
appears on resources that actually have events; a spec with none looks exactly
as before.
### Where events come from
Markline reads events from four sources and normalizes them into one model, so
you can use whichever your toolchain already emits:
| Source | Where it lives | Use it when |
| --- | --- | --- |
| `webhooks` | document root (OpenAPI **3.1**) | your spec is 3.1 and uses the native webhooks object |
| `x-webhooks` | document root (Redoc extension) | you're on 3.0 and want a Redoc-compatible root list |
| `callbacks` | on an operation | the event is a standard OpenAPI callback tied to one request |
| `x-events` | on a **tag** or an **operation** (Markline) | you want the lightest annotation, especially from codegen like NestJS |
For root `webhooks` / `x-webhooks`, each entry is a Path Item — Markline takes
its first method operation and reads `summary`, `description`, the operation's
`tags`, and the JSON request body as the payload. **A root webhook only appears
on a resource if its operation carries a matching `tags` entry**; untagged
webhooks are parsed but have no resource to attach to.
### The `x-events` extension
`x-events` is the most direct way to declare events. It takes either a
**map** of `name → definition`, or an **array of names** (references to events
defined elsewhere — no inline payload):
```jsonc
"x-events": {
"project.created": {
"summary": "A new project was created.",
"description": "Emitted once the project is provisioned and ready.",
"payload": { "$ref": "#/components/schemas/Project" },
"guide": "/guides/webhooks#project-created"
}
}
```
```jsonc
"x-events": ["project.created", "project.deleted"] // name-only references
```
Each definition field is optional:
One-line description, shown as the event's heading.
Longer prose shown under the summary.
The delivered payload. Inline schema or a `$ref` into `components.schemas` —
resolved at render to produce the attribute table and the sample JSON.
(`schema` and a JSON `requestBody` are accepted as fallbacks.)
Link to a docs page or anchor that explains the event in depth, rendered as a
follow-on link. (`guideHref` / `href` are accepted too.)
**Tag level vs. operation level** changes how the event is wired:
- On an **operation** (`paths...x-events`), the event inherits
that operation's `tags`, so it groups onto those resources — and because an
endpoint triggers it, the endpoint gets a **Triggers** chip and the event an
**Emitted by** back-link.
- On a **tag** (`tags[].x-events`), the event attaches to that resource only,
with no emitter back-link. Use this for events not tied to a single endpoint —
delivered by an external processor, a batch job, or a state change.
Markline **merges** the two per resource and de-duplicates by event name, so an
event declared on both a tag and an operation appears once.
The sample spec at `api/openapi.json` in this repo is wired up:
`createProject` emits `project.created`, `createDeployment` emits
`deployment.succeeded` / `deployment.failed`, and a root `x-webhooks` entry adds
`project.archived` to the Projects resource. Open the
[Projects reference](/api-reference/projects) to see the Events tab.
### From NestJS
NestJS has no `@ApiCallbacks`, but `@ApiExtension` is a clean fit — and it's why
`x-events` exists. Annotate the handler that causes the event; Markline
aggregates it onto the resource automatically (no document-root wiring):
```ts
@ApiTags('cards')
@ApiExtraModels(CardCreatedEvent) // emits the payload into components.schemas
@Controller('cards')
@Post()
@ApiExtension('x-events', {
'card.created': {
summary: 'A new card was issued',
payload: { $ref: getSchemaPath(CardCreatedEvent) },
guide: '/guides/webhooks#card-created',
},
})
create() { /* … */ }
}
```
- `@ApiExtraModels(CardCreatedEvent)` makes `getSchemaPath(...)` resolve — without
it the `$ref` points at a schema that was never emitted.
- The event inherits `@ApiTags('cards')`, so it lands on the **Cards** resource
and `POST /cards` gets the **Triggers** chip.
- `@ApiExtension` only enforces that the key starts with `x-`; the value is
passed through as-is, so objects, arrays and plain numbers all work. Repeated
`@ApiExtension` calls merge, so `x-events` and `x-nav-order` can sit on the
same handler. Placed on the **controller class** it applies to every route; on
a **handler**, just that operation.
Reuse a shared set with a small composed decorator:
```ts
applyDecorators(ApiExtension('x-events', events));
// @Emits({ 'card.created': { summary: 'A new card was issued', payload: { $ref: getSchemaPath(CardCreatedEvent) } } })
```
For an event with **no** single triggering endpoint (one a processor
delivers), attach it to the most relevant operation, or add it under a tag's
`x-events` by post-processing the generated document before you write
`openapi.json` — NestJS has no decorator for tag objects.
The [NestJS guide](/guides/nestjs) walks the whole setup end to end — emitting
the document, tags, ordering, events and code samples. There are matching guides
for [FastAPI](/guides/fastapi), [Spring Boot](/guides/spring-boot) and
[Hono](/guides/hono-zod-openapi).
## MDX overlays
Layer authored MDX on top of the generated reference — full components
(callouts, tables, tabs) included:
```
api/
├── openapi.json
├── introduction.mdx # replaces the whole reference landing
├── sections/.mdx # rich intro for a resource (overrides the tag description)
└── operations/.mdx # extra prose on one operation page
```
- **`sections/.mdx`** renders as the resource intro. The
filename is the slugified tag — `Payment Methods` → `payment-methods.mdx`.
- **`operations/.mdx`** renders between the endpoint path and the
auto-generated parameters.
## AI & page actions
Every resource section carries the same affordances as the docs: **Copy for
LLM** (copies the section as Markdown), **View as Markdown**, and — when
[Ask AI](/ai) is enabled — **Ask about this section**, which opens the docked
assistant with that section as context.
## Versions
The reference header shows a version selector. With a single spec it surfaces
the spec's `info.version` (a semver `1.4.2` → `v1 · 1.4.2`; a date `2025-06-01`
→ shown once, verbatim).
With multiple [versions](/versions) configured, each version ships its **own**
`/api/openapi.json`, served at `/api-reference/` — the selector switches
the spec in place and every link stays under that version's prefix. See
[Versions & i18n](/versions#api-reference-per-version) for the folder layout.
---
# Search
Full-text search across your docs and API reference — built into the static output, no service required.
Markline ships a command-palette search (⌘K) backed by a **static index** built
at compile time. There's nothing to run and no third-party search service.
## How it works
At build, Markline indexes your content — Markdown/MDX pages **and** the OpenAPI
reference — into a static index emitted alongside the site. At runtime the
palette loads that index in the browser and queries it client-side, so search
works on any static host.
The index is generated automatically by `markline build` / `markline
export`. It covers page titles, headings, body text, and API operations.
## Using it
- Press ⌘K (or /) anywhere, or click **Search docs** in the
header / **Find anything** in the API reference.
- Results are grouped and keyboard-navigable (↑/↓ to move, ↵ to go).
- When [Ask AI](/ai) is enabled, the palette also offers an **Ask Assistant**
row that hands your query to the docked assistant.
## Hosting
Search is part of the static build, so it works on any CDN, GitHub Pages, S3,
or a Node server. For **sub-path hosting**, set `MARKLINE_BASE_PATH=/prefix` so
the search bundle is loaded from the right path (see [Deployment](/deployment)).
---
# Deployment
Ship your docs as static HTML or a Node server — markline init scaffolds the configs.
## Static hosting
```bash
markline export # → ./out
```
Upload `out/` to any static host — a CDN, GitHub Pages, Netlify, or S3.
The scaffolded `.github/workflows/deploy.yml` exports and deploys on push to
`main`, building under `/` for project sites.
`netlify.toml` sets the build command to `npx markline export` and publishes `out`.
On static hosts the API playground runs in direct-fetch mode. Set
`api.playground.proxy` to `"never"`.
## Node server
```bash
markline build && markline start
```
This enables the playground's server-side proxy for APIs without CORS. The
scaffolded `Dockerfile` builds and serves the site:
```bash
docker build -t my-docs . && docker run -p 3000:3000 my-docs
```
## Sub-path hosting
Serving under a path prefix (e.g. a GitHub Pages project site)? Set the base path
so assets and search resolve correctly:
```bash
MARKLINE_BASE_PATH=/my-repo markline export
```
---
# Versions & i18n
Ship multiple documentation versions or languages from one project — same URL-prefix mechanism, a switcher in the header, fully static.
Markline serves multiple **versions** (v2, v1, …) or **locales** (en, es, …)
from a single project. Both use the same idea: the **first** entry is the
default and lives at the root; every other entry is served under a **URL
prefix** with its own content folder — and the header shows a switcher.
## How it works
There's one rule for the whole folder layout: **the default version lives at the
content root; every other version lives in a folder named after its `id`.** A
version folder is just a smaller mirror of the root — same `docs/` and
`api/openapi.json` inside.
```
content/
├── markline.json # config + the versions list
├── docs/ # DEFAULT version docs → /quickstart
│ └── quickstart.mdx
├── api/
│ └── openapi.json # DEFAULT version spec → /api-reference
└── v1/ # one folder per non-default version id
├── docs/ # v1 docs → /v1/quickstart
│ └── quickstart.mdx
└── api/
└── openapi.json # v1 spec → /api-reference/v1
```
So a version `id` is **both** a folder name and a URL prefix. The one wrinkle to
remember — docs and the API reference prefix the `id` in *different spots*:
| Content file | Default URL | Version `v1` URL |
| --- | --- | --- |
| `docs/quickstart.mdx` | `/quickstart` | `/v1/quickstart` |
| `api/openapi.json` | `/api-reference` | `/api-reference/v1` |
(Docs put the version first; the API reference keeps everything under
`/api-reference` and puts the version after it — so all API routes stay in one
place.)
A project uses **versions or locales**, not both — they share the
prefix mechanism (if both are set, versions win). The default entry is always
unprefixed, so existing URLs don't change when you add a second version. A
version folder only needs the parts that differ — drop `api/` and it's
docs-only; drop `docs/` and it's reference-only.
## Versions
List versions in `markline.json`. The first is the default; each entry is
`{ id, label }` and may carry its own `navigation`.
```jsonc
"versions": [
{ "id": "v2", "label": "v2 (latest)" }, // default — content in docs/, served at /
{ "id": "v1", "label": "v1" } // content in v1/docs/, served at /v1/…
]
```
Create `v1/docs/` and author its pages — they're independent from the
default, so v1 can lag or differ freely.
A version entry can set `navigation: { tabs: [...] }` to override the
sidebar for that version; otherwise it inherits the default navigation.
`markline build` emits every version statically. The header shows a
**version switcher**; picking one navigates under its prefix.
## API reference per version
Each version can ship its **own OpenAPI spec**. Drop it at
`/api/openapi.json` and Markline serves that version's reference at
`/api-reference/` — generated exactly like the default, with its own
resource pages, code samples, and interactive explorer.
```
content/
├── api/openapi.json # default → /api-reference
└── v1/
└── api/openapi.json # v1 → /api-reference/v1
```
- The **version selector** in the API-reference header lists every version and
switches the spec in place (the default is marked `Default`).
- Within a version, every link — resource pages, search results, the explorer's
endpoint switcher — stays under that version's `/api-reference/` prefix, so
you never fall back to the default spec mid-navigation.
- Per-resource and per-operation **MDX overlays** are version-scoped too:
`/api/sections/.mdx` and `/api/operations/.mdx`.
- The pill label follows the spec's `info.version` — a semver like `1.4.2` shows
as `v1 · 1.4.2`; a date like `2025-06-01` shows once, verbatim.
A version that has no `api/openapi.json` simply has no API reference (its
selector entry won't resolve a spec) — versions can be docs-only, reference-only,
or both.
## Localization
Locales work identically — `i18n.locales`, first is the default (unprefixed),
each other locale under its `id` prefix with content in `/docs/`.
```jsonc
"i18n": {
"locales": [
{ "id": "en", "label": "English" }, // default — docs/, served at /
{ "id": "es", "label": "Español" } // es/docs/, served at /es/…
]
}
```
```
content/
├── docs/ # English (default) → /quickstart
└── es/
└── docs/ # Spanish → /es/quickstart
```
The header shows a **language switcher** that jumps to the same page under the
chosen locale prefix.
## Notes
- The **default** entry (first in the list) is never prefixed — keep your
canonical content in `docs/`.
- Each variant is fully static and independently navigable; search and the API
reference work within each.
- Configure these in [Configuration](/configuration); for hosting the built
output see [Deployment](/deployment).
---
# AI for your documentation (BYOK)
An opt-in AI assistant for your docs and API reference — running on your own LLM key, never ours.
Markline ships an optional **Ask AI** assistant: a docked chat panel on the API
reference with "Ask about this section" and source citations. It is **bring your
own key (BYOK)** — Markline never provides a model or a key, and the feature is
strictly opt-in.
Omit the `ai` block (or set `enabled: false`) and there is no AI route, no
client weight, and no UI. The framework is byte-for-byte unchanged.
## How it works
Two principles drive the design:
1. **The key is a server-side secret.** In `proxy` mode it is read only from
`MARKLINE_AI_KEY` in the environment — never from `markline.json` (which is
committed and public), never from a `NEXT_PUBLIC_*` variable, never in the
browser bundle.
2. **One OpenAI-compatible transport.** A single code path speaks the
`/chat/completions` shape, so OpenAI, OpenRouter, Together, Groq, Fireworks
and local models (Ollama / LM Studio) all work by changing one `provider`
preset or `baseUrl`.
## Configuration
Add an `ai` block to `markline.json`, and provide the key via the environment.
```jsonc
// markline.json — committed, public. NO key here.
"ai": {
"enabled": true,
"provider": "openrouter", // preset, or "openai-compatible" + baseUrl
"model": "deepseek/deepseek-v4-flash",
"mode": "proxy", // "proxy" (operator key) | "byok" (reader key)
"endpoint": null, // external proxy URL (static hosting) — see below
"label": "Ask AI",
"maxTokens": 1024,
"rateLimit": { "perMinute": 10 }
}
```
```bash
# .env.local / host env — the secret. Never NEXT_PUBLIC_*.
MARKLINE_AI_KEY=sk-or-...
```
Restart `markline dev` after editing `markline.json` — config is read once
per process.
## Providers
Pick a `provider` preset, or use `openai-compatible` with an explicit `baseUrl`.
| `provider` | resolved base URL |
|---|---|
| `openai` | `https://api.openai.com/v1` |
| `openrouter` | `https://openrouter.ai/api/v1` |
| `together` | `https://api.together.xyz/v1` |
| `groq` | `https://api.groq.com/openai/v1` |
| `fireworks` | `https://api.fireworks.ai/inference/v1` |
| `local` | `http://localhost:11434/v1` (Ollama / LM Studio) |
| `openai-compatible` | **requires `baseUrl`** |
The `model` is the provider's model id — e.g. `deepseek/deepseek-v4-flash` or
`openai/gpt-4o-mini` on OpenRouter.
## Hosting modes
Where your docs run decides which mode fits:
| Host | Mode | Key location |
|---|---|---|
| Node / Docker / Vercel (`markline start`) | `proxy` | `MARKLINE_AI_KEY` on the server → built-in `/api/ai` route |
| Pure static (GitHub Pages / S3 / CDN) | `proxy` + `endpoint` | key on an external Worker you deploy (below) |
| Any host, including static | `byok` | the **reader's** key, in their browser |
`proxy` mode needs a server. The built-in `/api/ai` route is a dynamic
route and is **dropped in static export** — so on a pure-static host with
`mode: "proxy"` and no `endpoint`, `Ask AI` renders nothing. Use an `endpoint`
(below) or `mode: "byok"`.
In **`byok`** mode the assistant asks each reader for their own key (stored in
their browser) and calls the provider directly. It costs the operator nothing
and works on static hosting, at the price of a little reader friction.
## Static + Cloudflare Worker
To run Ask AI on **your** key from a **static** site, deploy the open-source
reference Worker in [`templates/ai-worker/`](https://github.com/markline-dev/markline/tree/main/templates/ai-worker).
It is the same proxy logic as the built-in route, plus the CORS handling a
cross-origin endpoint needs.
From `templates/ai-worker/`:
```bash
npm install
npx wrangler secret put MARKLINE_AI_KEY # paste your provider key
npm run deploy # → https://markline-ai..workers.dev
```
The key is stored as a Cloudflare secret on the edge — never in git, never
in the browser.
Edit `wrangler.toml` `[vars]` — `MARKLINE_AI_PROVIDER`, `MARKLINE_AI_MODEL`,
and `MARKLINE_ALLOWED_ORIGIN` (pin it to your docs domain; this is also the
abuse allow-list). Re-run `npm run deploy`.
In `markline.json`, set `mode: "proxy"` and `endpoint` to the Worker URL:
```jsonc
"ai": {
"enabled": true,
"mode": "proxy",
"provider": "openrouter",
"model": "deepseek/deepseek-v4-flash",
"endpoint": "https://markline-ai..workers.dev"
}
```
With `endpoint` set, the Ask AI UI appears even in a static export. Rebuild
and redeploy your docs.
## Cost & abuse
A public AI proxy is an open relay to your paid LLM — enabling it spends your
budget. Both the built-in route and the Worker ship with conservative defaults:
- **Origin allow-list** — only your docs domain(s) may call it.
- **Per-IP rate limit** — `ai.rateLimit.perMinute` (route) / `MARKLINE_RATE_PER_MIN` (Worker).
- **Token cap** — `ai.maxTokens` / `MARKLINE_AI_MAX_TOKENS`, plus an input-length clamp.
Tune the rate limit and pick a cheap model for public sites. The
Worker's rate limit is per-isolate in-memory; for strict global limits, back it
with Workers KV or a Durable Object.
---
# Feedback
"Was this helpful?" widgets on every docs page and API resource — opt-in, sink-agnostic, with a drop-in Cloudflare Worker for static hosting.
Markline ships optional **"Was this helpful?"** widgets on two surfaces:
- **Docs pages** — a per-**page** rate block in the TOC rail: 👍/👎 → a reason →
an optional comment → submit.
- **API reference** — a compact per-**resource** "Yes / No" that submits
immediately, tagging *which* resource it was on.
Both POST the **same JSON** to one endpoint, so any sink works. Answers are
remembered per page/section in `localStorage`, and a failed request never blocks
the reader — feedback is strictly best-effort.
Feedback is **hidden by default** — the widgets render only when you
configure them (below). Omit the `feedback` block and there is no widget and no
network at all.
## How it works
The framework is **sink-agnostic**: it only knows how to POST a small JSON blob
to `feedback.endpoint`. Where that lands — a webhook, a spreadsheet, a database,
the reference Worker — is yours to choose. Markline never stores feedback itself.
## Configuration
```jsonc
// markline.json
"feedback": {
"enabled": true, // optional — see the table
"endpoint": "https://feedback.example.com" // POST target
}
```
POST target for both widgets. Unset → submissions are logged to the browser
console (handy in dev).
Force the widgets on or off. Omitted → shown **only when an `endpoint` is set**
(so a widget never points nowhere).
Visibility resolves like this:
| `enabled` | `endpoint` | Widgets |
|---|---|---|
| _omitted_ | set | **shown** |
| _omitted_ | unset | hidden |
| `true` | any | **shown** (console-logs without an endpoint) |
| `false` | any | hidden |
Restart `markline dev` after editing `markline.json` — config is read once
per process.
## What it sends
A `POST` with `Content-Type: application/json`. The per-section payload is the
base; the per-page widget adds `reason` and `comment`.
```jsonc
// API reference — per section, on Yes/No
{ "answer": "yes", "scope": "section", "target": "payments",
"path": "/api-reference/payments", "ts": 1733424000000 }
```
```jsonc
// Docs — per page, on Submit
{ "answer": "no", "scope": "page", "target": null,
"path": "/guides/webhooks", "ts": 1733424000000,
"reason": "Update this documentation", "comment": "the retry section is stale" }
```
| Field | Type | Notes |
|---|---|---|
| `answer` | `"yes"` \| `"no"` | the thumb / Yes-No |
| `scope` | `"page"` \| `"section"` | which widget |
| `target` | `string` \| `null` | resource slug for sections; `null` for pages |
| `path` | `string` | the page it was left on |
| `ts` | `number` | epoch ms |
| `reason` | `string` | **page only** — one of the radio options |
| `comment` | `string` | **page only** — free text |
## Sinks
`feedback.endpoint` is just a URL that accepts that POST. Common choices:
- A **webhook** that fans out to Slack / Discord.
- A **Google Sheet** via an Apps Script web-app URL.
- **Airtable**, a serverless function, or your own API.
- The reference **Cloudflare Worker → D1** below — batteries-included for static
hosting.
## Cloudflare Worker + D1
For a static site (GitHub Pages / S3 / any CDN) with no backend of your own,
deploy the open-source reference Worker in
[`templates/feedback-worker/`](https://github.com/markline-dev/markline/tree/main/templates/feedback-worker).
It validates the payload, rate-limits, stores a row in **Cloudflare D1**, and can
forward a line to **Slack**.
From `templates/feedback-worker/`:
```bash
npm install
cp wrangler.toml.example wrangler.toml # your real config is gitignored
npx wrangler d1 create markline-feedback # paste the database_id into wrangler.toml
npx wrangler d1 execute markline-feedback --remote --file schema.sql
```
The `database_id` is an identifier, not a secret — but `wrangler.toml` is
gitignored so your filled-in config never lands in the repo.
In `wrangler.toml` `[vars]`, set `MARKLINE_ALLOWED_ORIGIN` to your docs
domain(s) — this is the CORS / abuse allow-list. To forward to Slack:
```bash
npx wrangler secret put MARKLINE_SLACK_WEBHOOK
```
```bash
npx wrangler deploy # → https://markline-feedback..workers.dev
```
Optionally add a [Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
(e.g. `feedback.your-docs.dev`) to hide the `workers.dev` URL, then set it in
`markline.json`:
```jsonc
"feedback": { "endpoint": "https://feedback.your-docs.dev" }
```
```bash
npx wrangler d1 execute markline-feedback --remote \
--command "SELECT created_at, answer, scope, target, path, reason, comment
FROM feedback ORDER BY id DESC LIMIT 50"
```
## Abuse & privacy
- **Origin allow-list** (`MARKLINE_ALLOWED_ORIGIN`) keeps other sites from posting
via a reader's browser — but it is **not** a security boundary (a script can
forge `Origin`). Your real control is the **rate limit**.
- **Per-IP rate limit** — `MARKLINE_RATE_PER_MIN` (default 30/min). It's
per-isolate in-memory; for a hard cap, back it with KV / a Durable Object or a
Cloudflare WAF rule.
- **No raw IPs** are stored — only a short hash, for spotting dupes/abuse.
- `comment` is **free text** and may contain PII; treat the table accordingly.
A public feedback endpoint is unauthenticated by design. Keep the
allow-list pinned and the rate limit conservative on high-traffic sites.
---
# Open-source Mintlify alternative
A modern docs experience without the SaaS — MIT-licensed, self-hostable, and yours to own.
# The open-source Mintlify alternative
Mintlify is a polished hosted docs platform — but it's a paid SaaS: your content
and build pipeline run on someone else's infrastructure, with per-seat pricing
and the usual platform lock-in. **Markline gives you the same modern docs
experience as an MIT-licensed framework you fully own and self-host.**
## Why teams switch
- **Own your stack.** Markline builds to plain static HTML you host anywhere —
GitHub Pages, S3/CloudFront, Cloudflare, or your own CDN. No vendor runtime.
- **No per-seat pricing.** It's MIT-licensed and free; add editors without a bill.
- **Bring your own AI key.** The Ask-AI assistant runs on *your* LLM key (BYOK),
not a metered hosted add-on.
- **First-class OpenAPI.** A generated API reference with an interactive
playground, three-pane layout, and per-language code samples.
- **No lock-in.** Your content is MDX in your repo; the engine is open source.
## Markline vs Mintlify
| | Markline | Mintlify |
| --- | --- | --- |
| License | Open source (MIT) | Proprietary |
| Hosting | Self-host anywhere (static export) | Hosted SaaS |
| Pricing | Free | Paid, per-seat |
| OpenAPI reference | ✓ | ✓ |
| Interactive playground | ✓ | ✓ |
| Search | Built-in, static (Pagefind) | ✓ |
| AI assistant | BYOK — your key | Hosted add-on |
| Content format | MDX in your repo | MDX on the platform |
| Lock-in | None | Platform |
Feature sets and pricing change — check the current details on each
project's site. Markline's column reflects what ships today.
## Migrate in minutes
Markline reads MDX guide pages and an `openapi.json`, so moving over is mostly
copying content. Point it at your docs and spec, set your theme, and ship:
Scaffold a project, drop in your MDX and OpenAPI spec, and deploy a static
site in a few minutes.
---
# Open-source Scalar alternative
More than an API reference — a full open-source docs framework that includes one.
# The open-source Scalar alternative
Scalar is a great open-source API reference. If you need **more than an API
reference** — full guide and tutorial pages, search across everything, and an AI
assistant — Markline is an open-source docs *framework* that does both, under one
site and one nav.
## Where Markline goes further
- **Docs *and* API in one site.** MDX guides and a generated OpenAPI reference
live under the same navigation, with instant client-side routing between them.
- **Instant static search** across your guides *and* the API reference — no
external service.
- **BYOK AI assistant** over your content, on your own LLM key.
- **Interactive playground** on the reference, with multi-language code samples.
- **One static build** you host anywhere.
## Markline vs Scalar
| | Markline | Scalar |
| --- | --- | --- |
| License | Open source (MIT) | Open source |
| Scope | Full docs framework (guides + API) | API reference / client |
| MDX guide pages | ✓ | Limited |
| OpenAPI reference | ✓ | ✓ |
| Interactive playground | ✓ | ✓ |
| Built-in search (guides + API) | ✓ | — |
| BYOK AI assistant | ✓ | — |
| Self-host (static) | ✓ | ✓ |
Both are open source — choose Scalar for a focused API reference, Markline
for a full docs site that includes one. Feature sets change; check each project's
site for current details.
## Get started
Point Markline at your MDX docs and `openapi.json` and ship a static docs +
API site in minutes.
---
# Framework guides
Point Markline at the OpenAPI document your backend already emits — then use four small extensions to shape the reference without touching your routes.
Markline doesn't ask you to describe your API twice. If your backend already
produces an OpenAPI 3.0 or 3.1 document — and most do — that document *is* your
reference. These guides cover the last mile: getting the document onto disk at
build time, and annotating it so the generated site reads the way you'd write it
by hand.
## What each guide covers
Every guide walks the same six steps, so you can skim across frameworks:
1. **Emit the document** into `api/openapi.json` as a build step.
2. **Tags become resources** — including nested `store/orders` tags.
3. **Order the sidebar** with `x-nav-order`.
4. **Document events** with `x-events`.
5. **Replace generated code samples** with `x-codeSamples`.
6. **Gotchas** — the framework-specific traps.
## The contract
The whole surface is four OpenAPI extensions plus your tags. Nothing here is
Markline-specific plumbing: they're plain JSON keys on a standard document, so
they survive `$ref` bundling, spec linting, and every other tool in the chain.
| What you want | Where it goes | Reference |
| --- | --- | --- |
| Group endpoints into resources | `tags` on the operation | [Nested tags](/openapi#nested-tags) |
| Reorder operations in a resource | `x-nav-order` on the operation | [Sidebar order](/openapi#sidebar-order) |
| Document webhooks / async events | `x-events` on a tag or operation | [Events & webhooks](/openapi#events-webhooks) |
| Hand-write the code rail | `x-codeSamples` on the operation | [Code samples](/openapi#code-samples) |
Every one of these is optional. A plain, unannotated document already
renders a complete reference — resource pages, parameter tables, generated
cURL/Node/Python/Go samples, and the request explorer. The extensions are for
when the default reading order isn't the one you want.
## Pick your framework
Decorator-driven, via `@nestjs/swagger` and `@ApiExtension`.
Native OpenAPI 3.1, shaped with `openapi_extra`.
springdoc-openapi, with `@Extension` and `@ExtensionProperty`.
Schema-first TypeScript, extensions inline in `createRoute`.
## Not listed?
You don't need a guide. Markline reads a standard document, so any generator
works — Django REST with drf-spectacular, Laravel, Rails with rswag, ASP.NET
with Swashbuckle, Go with huma or swaggo, or a hand-written YAML file.
The only thing a guide really buys you is the idiomatic way to attach an
`x-` key in that framework. If yours has no clean hook, post-process the emitted
document instead — the extensions are just JSON:
```js
// scripts/annotate-spec.mjs — run after your generator, before `markline build`
const spec = JSON.parse(readFileSync("api/openapi.json", "utf8"));
const order = {
createSession: 1,
endSession: 2,
};
for (const item of Object.values(spec.paths)) {
for (const op of Object.values(item)) {
const n = order[op.operationId];
if (n !== undefined) op["x-nav-order"] = n;
}
}
writeFileSync("api/openapi.json", JSON.stringify(spec, null, 2));
```
This is also the honest answer for tag-level `x-events` in frameworks
whose decorators only attach to handlers — see the note at the end of the
[NestJS guide](/guides/nestjs#gotchas).
---
# NestJS
Emit your OpenAPI document from @nestjs/swagger, snapshot it into your docs, and shape the reference with decorators that survive regeneration.
NestJS is the framework Markline's OpenAPI extensions were designed against.
`@nestjs/swagger` builds the document from decorators you already write, and
`@ApiExtension` gives you a clean hook for everything else — so the annotations
live next to the handler and survive every regeneration.
## Emit the document
`SwaggerModule.createDocument()` returns a plain object. Write it to
`api/openapi.json` in your docs project and commit it, so the docs build never
depends on a running service.
```ts
// scripts/emit-openapi.ts
async function main() {
const app = await NestFactory.create(AppModule, { logger: false });
const config = new DocumentBuilder()
.setTitle('Acme API') // ← names the generated SDK samples
.setVersion('1.4.2') // ← drives the version pill
.addServer('https://api.acme.com')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
mkdirSync('../docs/api', { recursive: true });
writeFileSync('../docs/api/openapi.json', JSON.stringify(document, null, 2));
await app.close();
}
main();
```
```jsonc
// package.json
"scripts": {
"docs:spec": "ts-node scripts/emit-openapi.ts",
"docs:build": "npm run docs:spec && cd ../docs && markline build"
}
```
`setTitle` is load-bearing. Markline derives the client name in generated
code samples from the first word of `info.title` — `"Acme API"` produces
`acme.payments.create({ … })`. See [how samples are
named](/openapi#code-samples).
## Tags become resources
`@ApiTags` is what Markline groups by. Slash-separated tags nest in the sidebar
without changing any URLs:
```ts
@ApiTags('store/orders')
@Controller('orders')
```
That renders a **Store** parent with an **Orders** child, still routed at
`/api-reference/store-orders`. Depth is arbitrary. See [Nested
tags](/openapi#nested-tags).
Declare the tags at the document level to control **resource** order — anything
you omit sorts after the listed tags, alphabetically:
```ts
const config = new DocumentBuilder()
.addTag('accounts')
.addTag('payments')
.addTag('store/orders')
.build();
```
## Order the sidebar
`@ApiTags` fixes which resource an endpoint lands on; `x-nav-order` fixes where
it sits *inside* that resource.
```ts
@ApiTags('accounts')
@Controller('accounts')
@Post()
@ApiExtension('x-nav-order', 1)
@ApiOperation({ summary: 'Create an account' })
create() { /* … */ }
@Get()
@ApiExtension('x-nav-order', 2)
@ApiOperation({ summary: 'List accounts' })
list() { /* … */ }
}
```
This is the case you **cannot** solve by reordering anything. Markline reads
verbs in a fixed order, so `GET /accounts` always precedes `POST /accounts` no
matter how the controller is written — `x-nav-order` is the only way to put
**Create an account** first.
A tiny composed decorator keeps it readable when you're ordering a whole
controller:
```ts
// @NavOrder(1)
```
Number them `10, 20, 30` rather than `1, 2, 3` and you can slot a new
endpoint in later without touching its neighbours.
## Events
NestJS has no `@ApiCallbacks`, which is exactly why `x-events` exists. Annotate
the handler that causes the event — Markline aggregates it onto the resource,
adds a **Triggers** chip to the endpoint, and an **Emitted by** back-link on the
event.
```ts
@ApiTags('accounts')
@ApiExtraModels(AccountCreatedEvent)
@Controller('accounts')
@Post()
@ApiExtension('x-events', {
'account.created': {
summary: 'A new account was opened',
payload: { $ref: getSchemaPath(AccountCreatedEvent) },
guide: '/guides/webhooks#account-created',
},
})
create() { /* … */ }
}
```
`@ApiExtraModels` is required — without it `getSchemaPath()` points at a schema
that was never emitted into `components.schemas`. Full details in [Events &
webhooks](/openapi#events-webhooks).
## Code samples
Markline generates cURL, Node, Python and Go rails from the operation itself.
If you ship a real SDK whose signatures don't match, replace the rail for that
operation:
```ts
@Post()
@ApiExtension('x-codeSamples', [
{
lang: 'ruby',
label: 'Ruby',
source: "Acme::Account.create(\n email: \"ada@example.com\",\n)",
},
])
create() { /* … */ }
```
`x-codeSamples` is all-or-nothing per operation — one custom sample
replaces the entire generated rail. List every language you want shown.
If you don't ship an SDK at all, don't annotate every operation. Set
`"codeSamples": ["curl"]` in `markline.json` and the invented
`acme.accounts.create(…)` snippets disappear everywhere at once.
## Gotchas
**`@ApiExtension` values aren't restricted to objects.** The decorator enforces
only that the key starts with `x-`; the value is cloned through as-is, so
`@ApiExtension('x-nav-order', 1)` is valid. Repeated calls merge, so `x-events`
and `x-nav-order` coexist on one handler.
**`@ApiExtension` on a controller class applies to every route in it.** Handy
for `x-events` that any endpoint in a resource can emit — and a trap for
`x-nav-order`, where it would give every operation the same rank.
**Operations need stable `operationId`s.** Markline routes per-operation deep
links and MDX overlays off `operationId`. NestJS derives it from the controller
and method name (`AccountsController_create`), so renaming a method breaks
existing links. Pin the ones you care about with
`@ApiOperation({ operationId: 'createAccount' })`.
**There's no decorator for tag objects.** An event with no single triggering
endpoint — delivered by a processor or a batch job — can't be attached via
decorators. Either hang it off the closest operation, or add it under
`tags[].x-events` in a [post-processing step](/guides#not-listed) after
`createDocument()` and before you write the file.
**Snapshot, don't proxy.** Pointing the docs build at a live `/api-json`
endpoint couples your docs deploy to your API being up. Emit to a committed
file; the diff also gives you a review surface for accidental API changes.
---
# Hono + Zod OpenAPI
Define routes and schemas once with @hono/zod-openapi, write the document to disk, and drop Markline's extensions straight into createRoute.
`@hono/zod-openapi` is the cleanest fit of the four. `createRoute()` takes an
OpenAPI **operation object** — it destructures `method`, `path`, `request` and
`responses` and spreads everything else through untouched — so Markline's
extensions go in as literal keys, no decorator or post-process step in between.
## Emit the document
`OpenAPIHono` exposes the document directly. Write it to `api/openapi.json` as a
build step:
```ts
// scripts/emit-openapi.ts
const doc = app.getOpenAPI31Document({
openapi: "3.1.0",
info: { title: "Acme API", version: "1.4.2" }, // ← client name + version pill
servers: [{ url: "https://api.acme.com" }],
});
mkdirSync("../docs/api", { recursive: true });
writeFileSync("../docs/api/openapi.json", JSON.stringify(doc, null, 2));
```
```jsonc
// package.json
"scripts": {
"docs:spec": "tsx scripts/emit-openapi.ts",
"docs:build": "npm run docs:spec && cd ../docs && markline build"
}
```
`getOpenAPI31Document()` emits 3.1, which unlocks native root `webhooks`.
`getOpenAPIDocument()` gives you 3.0 if you need it — Markline reads both.
`app.doc()` / `app.doc31()` mount the same document on a route; use those for
local inspection, not for the build.
## Tags become resources
`tags` on the route config is what Markline groups by, and slashes nest:
```ts
const createOrder = createRoute({
method: "post",
path: "/orders",
tags: ["store/orders"],
request: { body: { content: { "application/json": { schema: OrderCreate } } } },
responses: { 201: { description: "Created", content: { "application/json": { schema: Order } } } },
});
```
That renders a **Store** parent with an **Orders** child, routed at
`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags).
Set **resource** order in the document config — omitted tags sort after the
listed ones, alphabetically:
```ts
app.getOpenAPI31Document({
openapi: "3.1.0",
info: { title: "Acme API", version: "1.4.2" },
tags: [
{ name: "accounts", description: "Balances and statements." },
{ name: "payments" },
{ name: "store/orders" },
],
});
```
## Order the sidebar
`x-nav-order` is just another key on the route config:
```ts
const createAccount = createRoute({
method: "post",
path: "/accounts",
tags: ["accounts"],
operationId: "createAccount",
summary: "Create an account",
"x-nav-order": 1,
request: { body: { content: { "application/json": { schema: AccountCreate } } } },
responses: { 201: { description: "Created", content: { "application/json": { schema: Account } } } },
});
const listAccounts = createRoute({
method: "get",
path: "/accounts",
tags: ["accounts"],
operationId: "listAccounts",
summary: "List accounts",
"x-nav-order": 2,
responses: { 200: { description: "OK", content: { "application/json": { schema: z.array(Account) } } } },
});
```
It type-checks: `RouteConfig` extends OpenAPI's `OperationObject`, which allows
arbitrary `x-` keys.
These two routes are the case nothing else can fix — they share a path, and
Markline reads verbs in a fixed order, so `GET /accounts` would always come
first no matter how you register them. Number your operations `10, 20, 30` and
you can insert one later without renumbering.
## Events
On a 3.1 document you have both options.
**Native webhooks**, registered on the underlying registry:
```ts
app.openAPIRegistry.registerWebhook({
method: "post",
path: "account.created",
tags: ["accounts"],
responses: { 200: { description: "Acknowledged" } },
request: { body: { content: { "application/json": { schema: AccountCreatedEvent } } } },
});
```
A root webhook only attaches to a resource if it carries a matching
`tags` entry. Leave `tags` off and Markline parses it but has nowhere to put
it.
**`x-events`** is lighter and gives you the emitter cross-link — the endpoint
gets a **Triggers** chip, the event an **Emitted by** back-link:
```ts
const createAccount = createRoute({
method: "post",
path: "/accounts",
tags: ["accounts"],
"x-nav-order": 1,
"x-events": {
"account.created": {
summary: "A new account was opened",
payload: { $ref: "#/components/schemas/AccountCreatedEvent" },
guide: "/guides/webhooks#account-created",
},
},
// …
});
```
For that `$ref` to resolve, register the schema under a name:
```ts
app.openAPIRegistry.register("AccountCreatedEvent", AccountCreatedEvent);
```
Or use `AccountCreatedEvent.openapi("AccountCreatedEvent")` at definition time —
either way the component has to exist, or the payload renders empty. Full
behaviour in [Events & webhooks](/openapi#events-webhooks).
## Code samples
```ts
const createAccount = createRoute({
// …
"x-codeSamples": [
{
lang: "typescript",
label: "SDK",
source: 'await acme.accounts.create({ email: "ada@example.com" });',
},
],
});
```
One custom sample replaces the **entire** generated rail for that
operation — list every language you want shown.
No SDK? Skip this and set `"codeSamples": ["curl"]` in `markline.json`, which
drops the invented `acme.accounts.create(…)` snippets everywhere at once.
## Gotchas
**Set `operationId` yourself.** Without it the generator falls back to a derived
id, and Markline routes per-operation deep links and [MDX
overlays](/openapi#mdx-overlays) off `operationId`. An explicit `operationId` keeps
those URLs and overlay filenames stable across refactors.
**Register schemas you `$ref`.** A `$ref` to
`#/components/schemas/Foo` is only valid if `Foo` was registered — inline Zod
schemas get inlined into the operation, not hoisted. `.openapi("Foo")` or
`registry.register("Foo", schema)` hoists them, which also stops the same shape
being duplicated across every operation that uses it.
**Only the first tag groups.** `tags: ["accounts", "beta"]` lands the operation
on **Accounts**; the second tag doesn't create a second placement.
**`x-` keys are typed as `any`.** The spread-through is untyped, so a typo like
`"x-navorder"` compiles fine and is silently ignored. Markline reads
`x-nav-order`, and only when the value is a **number**.
**`z.array()` at the top level of a response is fine, but name it.** An unnamed
array response renders as an anonymous inline schema with no attribute table
heading. `z.array(Account).openapi("AccountList")` gives readers something to
anchor on.
---
# FastAPI
FastAPI already emits OpenAPI 3.1 with native webhooks. Dump it to a file at build time and shape the reference with openapi_extra.
FastAPI builds an OpenAPI document from your type hints and Pydantic models
with no extra annotation, and since 0.99 it emits **3.1** — so native
`webhooks` work out of the box. The only Markline-specific hook you need is
`openapi_extra`, which merges arbitrary keys onto an operation.
## Emit the document
`app.openapi()` returns the document as a dict. Write it to `api/openapi.json`
in your docs project as a build step rather than pointing the docs at a running
server.
```python
# scripts/emit_openapi.py
from pathlib import Path
from app.main import app
out = Path("../docs/api/openapi.json")
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(app.openapi(), indent=2))
```
```bash
python scripts/emit_openapi.py && (cd ../docs && markline build)
```
The document's `info` block comes from the app constructor, and it matters —
Markline derives the client name in generated code samples from the first word
of `info.title`, and the version pill from `info.version`:
```python
from fastapi import FastAPI
app = FastAPI(
title="Acme API", # → acme.accounts.create({ … }) in samples
version="1.4.2", # → the version pill
servers=[{"url": "https://api.acme.com"}],
)
```
## Tags become resources
Tags are what Markline groups by. Set them per-router so a whole module lands on
one resource, and use slashes to nest:
```python
from fastapi import APIRouter
router = APIRouter(prefix="/orders", tags=["store/orders"])
```
That renders a **Store** parent with an **Orders** child, routed at
`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags).
Control **resource** order — and add resource descriptions — with
`openapi_tags`. Tags you omit sort after the listed ones, alphabetically:
```python
app = FastAPI(
title="Acme API",
openapi_tags=[
{"name": "accounts", "description": "Balances and statements."},
{"name": "payments"},
{"name": "store/orders"},
],
)
```
## Order the sidebar
`openapi_extra` merges straight onto the operation object, which is exactly
where `x-nav-order` belongs:
```python
@router.post("", openapi_extra={"x-nav-order": 1})
async def create_account(body: AccountCreate) -> Account:
...
@router.get("", openapi_extra={"x-nav-order": 2})
async def list_accounts() -> list[Account]:
...
```
This is the case reordering can't fix: Markline reads verbs in a fixed order,
so `GET /accounts` always precedes `POST /accounts` regardless of the order you
declare the handlers in. `x-nav-order` is the only way to open the resource on
**Create account**.
Number them `10, 20, 30` and you can insert an endpoint later without touching
its neighbours. Operations you don't annotate keep document order, after the
ordered ones.
## Events
On 3.1 you have two options, and Markline reads both.
**Native webhooks** — the idiomatic FastAPI route, available since 0.99:
```python
@app.webhooks.post("account.created")
async def account_created(body: AccountCreatedEvent):
"""A new account was opened."""
```
FastAPI emits this under the document root's `webhooks` object.
A root webhook only attaches to a resource if its operation carries a
matching `tags` entry — otherwise Markline parses it but has nowhere to show it.
Pass `tags=["accounts"]` to the webhook decorator.
**`x-events`** — lighter, and it gives you the emitter cross-link. Put it on the
operation that causes the event and the endpoint gets a **Triggers** chip while
the event gets an **Emitted by** back-link:
```python
@router.post(
"",
openapi_extra={
"x-nav-order": 1,
"x-events": {
"account.created": {
"summary": "A new account was opened",
"payload": {"$ref": "#/components/schemas/AccountCreatedEvent"},
"guide": "/guides/webhooks#account-created",
}
},
},
)
async def create_account(body: AccountCreate) -> Account:
...
```
For that `$ref` to resolve, `AccountCreatedEvent` has to actually reach
`components.schemas`. If no endpoint returns or accepts it, FastAPI won't emit
it — reference the model from a response somewhere, or inline the payload
schema instead of using `$ref`.
Full behaviour in [Events & webhooks](/openapi#events-webhooks).
## Code samples
Replace the generated rail on an operation when you ship an SDK whose calls
don't match Markline's inferred ones:
```python
@router.post(
"",
openapi_extra={
"x-codeSamples": [
{
"lang": "python",
"label": "Python SDK",
"source": 'client.accounts.create(email="ada@example.com")',
}
]
},
)
async def create_account(body: AccountCreate) -> Account:
...
```
One custom sample replaces the **entire** generated rail for that
operation. List every language you want shown.
No SDK at all? Skip the annotation and set `"codeSamples": ["curl"]` in
`markline.json` — that suppresses the invented SDK snippets everywhere at once.
## Gotchas
**`operationId`s are ugly and unstable by default.** FastAPI derives them from
the function name, path and method — `create_account_accounts_post`. Markline
routes per-operation deep links and [MDX overlays](/openapi#mdx-overlays) off
`operationId`, so those names end up in URLs and overlay filenames. Pin them:
```python
@router.post("", operation_id="createAccount")
```
Or normalise the whole app once, before emitting:
```python
for route in app.routes:
if isinstance(route, APIRoute):
route.operation_id = route.name
```
**`app.openapi()` caches.** It memoises into `app.openapi_schema`, so if you
mutate routes after the first call you'll dump a stale document. In a one-shot
emit script this never bites; in a longer script, set
`app.openapi_schema = None` before re-reading.
**`openapi_extra` merges, it doesn't validate.** A typo like `x-navorder` is
silently ignored — Markline only reads `x-nav-order`, and only when the value is
a **number**. `"x-nav-order": "1"` is dropped.
**Only the first tag counts for grouping.** An operation with
`tags=["accounts", "beta"]` lands on **Accounts**; the second tag doesn't create
a second placement.
**Pydantic aliases show up verbatim.** Markline renders the emitted schema, so
`alias`/`serialization_alias` names are what your readers see. That's usually
what you want — just be aware the docs follow the wire format, not your Python
attribute names.
---
# Spring Boot
Pull the document springdoc-openapi already serves at /v3/api-docs into your docs build, then shape the reference with swagger-core's @Extension annotations.
springdoc-openapi introspects your Spring MVC or WebFlux controllers and serves
the document at `/v3/api-docs`. Everything Markline needs beyond that comes from
swagger-core's `@Extension` / `@ExtensionProperty` annotations — with one sharp
edge around value types that's worth reading before you start.
## Emit the document
springdoc generates at runtime, so the build step is "start the app, take a
snapshot." The official Maven plugin does exactly that during
`integration-test`:
```xml
org.springdoc
springdoc-openapi-maven-plugin
1.4
integration-test
generate
http://localhost:8080/v3/api-docs
openapi.json
${project.basedir}/../docs/api
```
Pair it with `spring-boot-maven-plugin`'s `start` / `stop` goals in
`pre-integration-test` / `post-integration-test`. On Gradle, the
`org.springdoc.openapi-gradle-plugin` equivalent does the same.
If you'd rather not wire a plugin, the framework-agnostic version is two lines
and works everywhere:
```bash
java -jar target/app.jar &
until curl -sf localhost:8080/v3/api-docs -o ../docs/api/openapi.json; do sleep 1; done
kill %1
```
Set the document metadata once — `info.title` names the client in generated code
samples, `info.version` drives the version pill:
```java
@OpenAPIDefinition(
info = @Info(title = "Acme API", version = "1.4.2"),
servers = @Server(url = "https://api.acme.com")
)
@SpringBootApplication
public class AcmeApplication { }
```
## Tags become resources
`@Tag` on the controller is what Markline groups by, and slashes nest:
```java
@Tag(name = "store/orders")
@RestController
@RequestMapping("/orders")
public class OrderController { }
```
That renders a **Store** parent with an **Orders** child, routed at
`/api-reference/store-orders`. See [Nested tags](/openapi#nested-tags).
Declare tags at the document level to fix **resource** order and give each one a
description — anything you omit sorts after the listed tags, alphabetically:
```java
@OpenAPIDefinition(
info = @Info(title = "Acme API", version = "1.4.2"),
tags = {
@Tag(name = "accounts", description = "Balances and statements."),
@Tag(name = "payments"),
@Tag(name = "store/orders")
}
)
```
## Order the sidebar
Here's the sharp edge. `@ExtensionProperty.value()` is a **`String`**, and
`parseValue()` defaults to `false` — so the obvious spelling emits
`"x-nav-order": "1"`, a JSON *string*. Markline only honours `x-nav-order` when
it's a **number**, so a string is silently ignored and nothing moves.
Set `parseValue = true`:
```java
@Operation(
summary = "Create an account",
extensions = @Extension(properties = @ExtensionProperty(
name = "x-nav-order", value = "1", parseValue = true
))
)
@PostMapping
public Account create(@RequestBody AccountCreate body) { … }
```
Leave `parseValue` off and you get `"x-nav-order": "1"`. It's valid
OpenAPI, it lints clean, and it does nothing. This is the single most common way
to wire this up wrong on the JVM.
Note the empty `@Extension` name: with no `name`, swagger-core promotes each
property to a **top-level** extension key (prefixing `x-` if you left it off).
Give `@Extension` a name and you get a nested object instead — which is what you
want for `x-events` below, and not what you want here.
This is also the case that can't be solved by reordering source. Markline reads
verbs in a fixed order, so `GET /accounts` always precedes `POST /accounts`
however the controller is written. Number your operations `10, 20, 30` and you
can insert one later without renumbering its neighbours.
## Events
Give `@Extension` a **name** and its properties become a nested object — which
is exactly the shape `x-events` wants. The payload is JSON, so it needs
`parseValue = true` too:
```java
@Operation(
summary = "Create an account",
extensions = @Extension(name = "events", properties = @ExtensionProperty(
name = "account.created",
value = """
{
"summary": "A new account was opened",
"payload": { "$ref": "#/components/schemas/AccountCreatedEvent" },
"guide": "/guides/webhooks#account-created"
}
""",
parseValue = true
))
)
@PostMapping
public Account create(@RequestBody AccountCreate body) { … }
```
`@Extension(name = "events", …)` produces the key `x-events` — swagger-core
prepends `x-` for you. Placed on the operation, the event inherits the
controller's `@Tag`, so `POST /accounts` gets a **Triggers** chip and the event
an **Emitted by** back-link.
The `$ref` only resolves if `AccountCreatedEvent` reached
`components.schemas`. If no endpoint returns it, add
`@Schema(implementation = AccountCreatedEvent.class)` somewhere reachable, or
inline the payload schema instead of using `$ref`.
Full behaviour in [Events & webhooks](/openapi#events-webhooks).
## Code samples
`x-codeSamples` is an **array**, which the nested-object form can't express. Use
the empty-name form with a JSON array as the value:
```java
@Operation(
extensions = @Extension(properties = @ExtensionProperty(
name = "x-codeSamples",
value = """
[{ "lang": "java", "label": "Java SDK",
"source": "acme.accounts().create(AccountCreate.of(\\"ada@example.com\\"));" }]
""",
parseValue = true
))
)
```
One custom sample replaces the **entire** generated rail for that
operation — list every language you want shown.
If you don't publish an SDK, don't annotate anything: set
`"codeSamples": ["curl"]` in `markline.json` and the invented
`acme.accounts.create(…)` snippets disappear everywhere at once.
## Gotchas
**`parseValue = true` on every non-string value.** Numbers, booleans, objects
and arrays all arrive as strings otherwise. It's the first thing to check when
an extension "doesn't work."
**Blank values are dropped.** swagger-core skips any `@ExtensionProperty` whose
name or value is blank, so an empty string won't clear an inherited value.
**Empty `@Extension` name vs named.** No name → each property becomes its own
top-level `x-` key. A name → one `x-` key holding a map of the properties.
Picking the wrong one is the second most common failure.
**`operationId`s collide and get suffixed.** springdoc derives them from the
method name and appends `_1`, `_2` on collision. Markline routes per-operation
deep links and [MDX overlays](/openapi#mdx-overlays) off `operationId`, so those
suffixes leak into URLs and filenames — and shift when you add a method. Pin
them with `@Operation(operationId = "createAccount")`.
**Only the first tag groups.** A controller with two `@Tag`s puts its operations
on the first one; the second doesn't create a second placement.
**Text blocks and quotes.** A Java text block takes bare `"` happily, so the
JSON keys and values above need no escaping. The exception is a quote that has
to survive *into* the JSON string — write `\\"` so the runtime string holds
`\"`, which the JSON parser then reads as a quote. Write `\"` and the block
collapses it to `"`, terminating the JSON string early.