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.
// scripts/emit-openapi.ts
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync, mkdirSync } from 'node:fs';
import { AppModule } from '../src/app.module';
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();
// 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.Tags become resources
@ApiTags is what Markline groups by. Slash-separated tags nest in the sidebar
without changing any URLs:
@ApiTags('store/orders')
@Controller('orders')
export class OrdersController {}
That renders a Store parent with an Orders child, still routed at
/api-reference/store-orders. Depth is arbitrary. See Nested
tags.
Declare the tags at the document level to control resource order — anything you omit sorts after the listed tags, alphabetically:
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.
import { ApiExtension, ApiOperation, ApiTags } from '@nestjs/swagger';
@ApiTags('accounts')
@Controller('accounts')
export class AccountsController {
@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:
import { applyDecorators } from '@nestjs/common';
import { ApiExtension } from '@nestjs/swagger';
export const NavOrder = (n: number) => applyDecorators(ApiExtension('x-nav-order', n));
// @NavOrder(1)
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.
import { ApiExtension, ApiExtraModels, ApiTags, getSchemaPath } from '@nestjs/swagger';
@ApiTags('accounts')
@ApiExtraModels(AccountCreatedEvent)
@Controller('accounts')
export class AccountsController {
@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.
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:
@Post()
@ApiExtension('x-codeSamples', [
{
lang: 'ruby',
label: 'Ruby',
source: "Acme::Account.create(\n email: \"[email protected]\",\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 operationIds. 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 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.