Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
Guides

API reference

Publish NestJS API documentation from Swagger metadata

Turn NestJS controllers and DTOs into a docs site: a Swagger JSON export that needs no database, a page per endpoint with its auth, and a first-request guide beside them.

By 12 min read

To publish NestJS API documentation, let @nestjs/swagger build the OpenAPI document from your controllers and DTOs, write it to a JSON file with a short export script, and point Blume's openapi() adapter at that file. Blume turns every operation into its own page, with its schemas, auth requirements, code samples, and a Try it panel, and puts those pages next to guides you write in Markdown.

By the end, you have a NestJS app whose DTOs and guards describe themselves, a spec export that runs without a database, and a docs site with a first-request guide beside the reference. The example is the fictional Acme Messages API. It was tested with NestJS 12.1.0, @nestjs/swagger 12.0.2, and the Nest CLI 12.0.7.

If you only need an interactive console for your own team, the Swagger UI that SwaggerModule.setup() serves already covers it. Blume is for a public site where readers find endpoints through search, land on each one at its own URL, and read guides in the same sidebar. If you already have a spec file, start with Generate API docs from an OpenAPI spec instead.

How the pieces fit

The docs site lives in a docs-site/ folder inside the Nest repository, as its own Blume project. The Nest app writes docs-site/openapi.json, and Blume reads it at build time. Blume builds its site into dist/, the same folder name Nest uses, which is why it needs a folder of its own.

acme-api/
├── nest-cli.json
├── package.json
├── src/
│   ├── app.module.ts
│   ├── auth/
│   │   ├── api-key.guard.ts
│   │   └── authenticated.decorator.ts
│   ├── export-openapi.ts
│   ├── main.ts
│   ├── messages/
│   │   ├── dto/
│   │   │   ├── message.dto.ts
│   │   │   └── send-message.dto.ts
│   │   └── messages.controller.ts
│   └── openapi.ts
└── docs-site/
    ├── blume.config.ts
    ├── docs/
    │   ├── api/quickstart.mdx
    │   └── index.mdx
    ├── openapi.json
    └── package.json

The code uses the ESM layout, one of the two that nest new offers in NestJS 12, with .js extensions on relative imports. In a CommonJS project, drop those extensions and wrap the export script's top-level awaits in an async function.

Add Swagger and its CLI plugin

Install the Swagger module and the validation packages the DTOs use:

npm install @nestjs/swagger@12.0.2 class-validator@0.15.1 class-transformer@0.5.1

Then turn on the Swagger CLI plugin. It runs while TypeScript compiles and writes the schema metadata for every DTO property, so you don't repeat each type in an @ApiProperty() decorator. With introspectComments, it also turns doc comments into descriptions and examples:

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "plugins": [
      {
        "name": "@nestjs/swagger",
        "options": { "introspectComments": true }
      }
    ]
  }
}

The plugin only runs when the Nest CLI compiles your code, with nest build or nest start. Plain tsc, tsx, and ts-node skip it, and the schemas come out empty. It also only reads files named *.dto.ts or *.entity.ts by default.

Describe the DTOs

Write DTOs as plain classes. The plugin reads the TypeScript types, the ? on optional properties, the class-validator decorators, and the doc comments:

import { IsIn, IsObject, IsOptional, IsString } from 'class-validator';

export class SendMessageDto {
  /**
   * How to deliver the message.
   * @example 'email'
   */
  @IsIn(['email', 'sms'])
  channel: 'email' | 'sms';

  /**
   * An email address, or a phone number in E.164 format.
   * @example 'ada@example.com'
   */
  @IsString()
  to: string;

  /**
   * The ID of the template to send.
   * @example 'welcome'
   */
  @IsString()
  template: string;

  /**
   * Values for the template's variables.
   * @example { "firstName": "Ada" }
   */
  @IsOptional()
  @IsObject()
  variables?: Record<string, string>;
}
export class MessageDto {
  /**
   * The message ID.
   * @example 'msg_8f2k'
   */
  id: string;

  /** Where the message is in delivery. */
  status: 'queued' | 'sent' | 'delivered' | 'failed';

  /** How the message is delivered. */
  channel: 'email' | 'sms';
}

From SendMessageDto, the plugin produces this schema. The union becomes an enum, the record becomes additionalProperties, and only variables is left out of required:

"SendMessageDto": {
  "type": "object",
  "properties": {
    "channel": {
      "type": "string",
      "description": "How to deliver the message.",
      "example": "email",
      "enum": ["email", "sms"]
    },
    "to": {
      "type": "string",
      "description": "An email address, or a phone number in E.164 format.",
      "example": "ada@example.com"
    },
    "template": {
      "type": "string",
      "description": "The ID of the template to send.",
      "example": "welcome"
    },
    "variables": {
      "type": "object",
      "additionalProperties": { "type": "string" },
      "description": "Values for the template's variables.",
      "example": { "firstName": "Ada" }
    }
  },
  "required": ["channel", "to", "template"]
}

Blume renders those descriptions in the schema tables and uses the examples to fill the Try it form, so the comments you write here are the reference readers see.

Protect the controller and document it once

A guard enforces authentication, and @ApiBearerAuth() documents it. NestJS doesn't connect the two, so a route can require a key without saying so in the spec, or the other way around. Put both in one decorator, and they can't drift apart. First, the guard:

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import type { Request } from 'express';

@Injectable()
export class ApiKeyGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest<Request>();
    const [scheme, token] = request.headers.authorization?.split(' ') ?? [];
    if (scheme !== 'Bearer' || !token || token !== process.env.ACME_API_KEY) {
      throw new UnauthorizedException('Send a valid API key as a bearer token.');
    }
    return true;
  }
}

Then the decorator that applies the guard and documents it together:

import { applyDecorators, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
import { ApiKeyGuard } from './api-key.guard.js';

export const Authenticated = () =>
  applyDecorators(
    UseGuards(ApiKeyGuard),
    ApiBearerAuth(),
    ApiUnauthorizedResponse({ description: 'The API key is missing or invalid.' }),
  );

Use it on the controller, so every route in it is protected and documented as protected:

import {
  Body,
  Controller,
  Get,
  HttpCode,
  NotFoundException,
  Param,
  Post,
} from '@nestjs/common';
import {
  ApiAcceptedResponse,
  ApiBadRequestResponse,
  ApiNotFoundResponse,
  ApiOkResponse,
  ApiOperation,
  ApiParam,
  ApiTags,
} from '@nestjs/swagger';
import { Authenticated } from '../auth/authenticated.decorator.js';
import { MessageDto } from './dto/message.dto.js';
import { SendMessageDto } from './dto/send-message.dto.js';

@ApiTags('Messages')
@Authenticated()
@Controller('messages')
export class MessagesController {
  private readonly messages = new Map<string, MessageDto>();

  @Post()
  @HttpCode(202)
  @ApiOperation({
    summary: 'Send a message',
    description: 'Queues an email or SMS for delivery and returns its ID right away.',
  })
  @ApiAcceptedResponse({ description: 'The message is queued.', type: MessageDto })
  @ApiBadRequestResponse({ description: 'The body failed validation.' })
  sendMessage(@Body() body: SendMessageDto): MessageDto {
    const message: MessageDto = {
      id: `msg_${crypto.randomUUID().slice(0, 8)}`,
      status: 'queued',
      channel: body.channel,
    };
    this.messages.set(message.id, message);
    return message;
  }

  @Get(':id')
  @ApiOperation({
    summary: 'Get a message',
    description: 'Returns a message and its delivery status.',
  })
  @ApiParam({
    name: 'id',
    description: 'The message ID, from the response to Send a message.',
    example: 'msg_8f2k',
  })
  @ApiOkResponse({ description: 'The message.', type: MessageDto })
  @ApiNotFoundResponse({ description: 'No message has that ID.' })
  getMessage(@Param('id') id: string): MessageDto {
    const message = this.messages.get(id);
    if (!message) {
      throw new NotFoundException(`No message has the ID ${id}.`);
    }
    return message;
  }
}

The in-memory map stands in for your database. Each operation's summary becomes its page title and sidebar label in Blume, and its description becomes the page's introduction and search description, so keep the summary to a few words. The tag becomes the sidebar group and part of every page URL.

Build the OpenAPI document

Put the document settings in one function, so the running server and the export script produce the same spec:

import type { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

export function createOpenApiDocument(app: INestApplication) {
  const config = new DocumentBuilder()
    .setTitle('Acme Messages API')
    .setDescription('Send transactional email and SMS, and check whether they arrived.')
    .setVersion('1.0.0')
    .addServer('https://api.acme.example/v1')
    .addTag('Messages', 'Send a message and check whether it arrived.')
    .addBearerAuth({
      type: 'http',
      scheme: 'bearer',
      bearerFormat: 'API key',
      description: 'An API key, sent as a bearer token.',
    })
    .build();

  return SwaggerModule.createDocument(app, config, {
    ignoreGlobalPrefix: true,
    operationIdFactory: (_controllerKey, methodKey) => methodKey,
  });
}

Three of these settings decide how the Blume site turns out:

  • operationIdFactory sets the operation IDs that Blume builds page URLs from. NestJS defaults to MessagesController_sendMessage, which becomes /api/messages/messages-controller-send-message. Using the method name gives /api/messages/send-message. Method names must then be unique across the whole API.
  • ignoreGlobalPrefix keeps /v1 out of the paths, because the server URL already ends in /v1. Put the prefix in one place or the other, never both.
  • bearerFormat labels the credential. addBearerAuth() defaults it to JWT, and Blume shows it in each operation's Authorization section, as "Bearer token (API key)" here.

Register the controller, then use the function in main.ts:

import { Module } from '@nestjs/common';
import { MessagesController } from './messages/messages.controller.js';

@Module({
  controllers: [MessagesController],
})
export class AppModule {}
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module.js';
import { createOpenApiDocument } from './openapi.js';

const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('v1');
app.enableCors({ origin: 'https://docs.acme.example' });
app.useGlobalPipes(new ValidationPipe({ whitelist: true }));
SwaggerModule.setup('swagger', app, () => createOpenApiDocument(app));
await app.listen(process.env.PORT ?? 3000);

enableCors lets the docs site's Try it panel call the API from a reader's browser. The Swagger UI at /swagger is optional: the docs site doesn't use it. Start the server with an API key and check that the guard holds:

ACME_API_KEY=test_key npm run start:dev

curl -i -X POST http://localhost:3000/v1/messages \
  -H "Content-Type: application/json" \
  -d '{"channel":"email","to":"ada@example.com","template":"welcome"}'

Without the key, the API answers 401. Add -H "Authorization: Bearer test_key" and it answers 202 with the queued message.

Export the Swagger JSON without starting the server

While the server runs, the spec is at http://localhost:3000/swagger-json. A build shouldn't depend on a running server, though, so add a script that writes the file directly:

import { writeFile } from 'node:fs/promises';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { createOpenApiDocument } from './openapi.js';

// Preview mode maps controllers and routes without running any constructors,
// so the export needs no database or other services.
const app = await NestFactory.create(AppModule, {
  logger: ['error'],
  preview: true,
});
// Repeat anything from main.ts that changes routes.
app.setGlobalPrefix('v1');

const document = createOpenApiDocument(app);
await writeFile('docs-site/openapi.json', `${JSON.stringify(document, null, 2)}\n`);
await app.close();

In preview mode, NestJS builds the module graph but doesn't run the constructors or lifecycle hooks of your providers and controllers. A TypeORM or Prisma module stays disconnected, and the document still lists every route. Add a script to the Nest app's package.json that builds with the plugin first:

"scripts": {
  "openapi": "nest build && node dist/export-openapi.js"
}

Keep your existing scripts beside it. You'll run it once the docs-site/ folder exists, in the next section. The output is the same on every run, so the file only changes when your API does.

Create the docs site

From the root of the Nest repository, scaffold a Blume project in docs-site/, then export the spec into it:

npx blume init docs-site --template docs --yes
npm run openapi

init writes docs-site/package.json, a config, a home page at docs-site/docs/index.mdx, and a .gitignore, then installs Blume. The second command writes docs-site/openapi.json. Replace the generated config with this one:

import { defineConfig } from "blume";
import { openapi } from "blume/reference";

export default defineConfig({
  title: "Acme Docs",
  description: "Guides and API reference for the Acme Messages API.",
  deployment: { site: "https://docs.acme.example" },
  navigation: {
    tabs: [
      { label: "Docs", path: "/" },
      { label: "API reference", path: "/api" },
    ],
  },
  reference: [openapi({ route: "/api", spec: "./openapi.json" })],
});

The reference doesn't add a header tab on its own, so the tab pointing at /api is what makes it reachable. NestJS writes OpenAPI 3.0, which Blume upgrades to 3.1 as it reads the file. Start the docs:

cd docs-site
npm run dev

The API reference tab now holds three pages built from your code:

PageURL
Overview, with the API's description and its Messages section/api
Send a message/api/messages/send-message
Get a message/api/messages/get-message

Because the controller carries @ApiBearerAuth(), both operations show an Authorization section, and their code samples send Authorization: Bearer YOUR_TOKEN. A route without it gets neither, so a public health check stays public in the docs too. The dev server doesn't watch the spec file, so restart it after you run npm run openapi again.

Add a first-request guide

The reference says what each endpoint takes. A new reader also needs to know how to make the first call and what an error looks like. Put that page in docs/api/, a folder named after the reference route, and it joins the API reference tab's sidebar beside the generated pages:

---
title: Send your first message
description: Authenticate with an API key, send an email through the Acme Messages API, and check whether it was delivered.
---

Every request needs an API key, sent in the `Authorization` header as a
bearer token. Set it in your shell first:

```bash
export ACME_API_KEY="your-api-key"
```

## Send a message

```bash
curl https://api.acme.example/v1/messages \
  -H "Authorization: Bearer $ACME_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"channel":"email","to":"ada@example.com","template":"welcome"}'
```

The API queues the message and answers `202 Accepted` with its ID:

```json
{ "id": "msg_8f2k", "status": "queued", "channel": "email" }
```

## Check whether it arrived

Pass the ID to [Get a message](/api/messages/get-message):

```bash
curl https://api.acme.example/v1/messages/msg_8f2k \
  -H "Authorization: Bearer $ACME_API_KEY"
```

Its `status` is one of `queued`, `sent`, `delivered`, or `failed`.

## Read an error

Every error is JSON with the status code, its name, and a message. A body
that fails validation lists each problem:

```json
{
  "message": [
    "channel must be one of the following values: email, sms",
    "template must be a string"
  ],
  "error": "Bad Request",
  "statusCode": 400
}
```

A missing or wrong API key returns `401` with the message
"Send a valid API key as a bearer token."

The error bodies are the ones NestJS sends: ValidationPipe writes the list of problems, and the guard's UnauthorizedException writes the 401. Copy them from your own API's responses, so the guide matches what readers will see.

Keep the spec current

Commit docs-site/openapi.json. API changes then show up in pull requests as a readable diff, and the docs host never has to build or boot your Nest app. To make sure nobody forgets to regenerate it, run the export in CI and fail when the file changes:

name: OpenAPI spec

on:
  pull_request:

jobs:
  spec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run openapi
      - run: git diff --exit-code -- docs-site/openapi.json

When the check fails, run npm run openapi locally and commit the result. For breaking-change detection and more, see Keep API documentation in sync with backend changes in CI.

Deploy

The docs build to static files, so any static host works. Create a project on your host from the same repository and point it at the docs-site/ folder:

SettingValue
Root directorydocs-site
Build commandnpm run build
Output directorydist
Node.js version22.12 or later

Vercel calls the first setting Root Directory, and Netlify calls it Base directory. deployment.site in the config gives the sitemap, canonical links, and Open Graph images their absolute URLs. Before you point your domain at it, run npm run build and npx blume validate in docs-site/, which checks every internal link, including the guide's link to Get a message.

Then deploy the API with its enableCors change. The Try it panel sends requests from the reader's browser straight to the server in your spec, so without it, Send fails while the same request works from curl. If you can't change the API's CORS rules, Blume can proxy the requests instead, and Fix CORS errors in an API documentation playground walks through both options.

Troubleshooting

Schemas have no properties

If docs-site/openapi.json shows "properties": {} for a DTO, the Swagger plugin didn't process it. Check that the export ran on code built with nest build, not tsc, tsx, or ts-node, and that the DTO's file name ends in .dto.ts or .entity.ts. With the SWC builder, the plugin needs type checking turned on; see the NestJS SWC recipe. If you can't use the plugin, add @ApiProperty() to every DTO property instead.

Page titles are whole paragraphs

With introspectComments, the plugin copies a controller method's entire doc comment into summary, and Blume uses the summary as the page title. Set summary and description in @ApiOperation() as shown above, or move everything after the first line under a @remarks tag, which the plugin maps to description.

URLs read messages-controller-send-message

That's NestJS's default operation ID, ControllerName_methodName. Set operationIdFactory as in src/openapi.ts. If the old URLs are already public, add a redirect for each one in docs-site/blume.config.ts.

Try it sends requests to /v1/v1/messages

The global prefix is in both the paths and the server URL. Without ignoreGlobalPrefix: true, NestJS writes the paths as /v1/messages. Keep the option, or drop /v1 from addServer().

The export fails while connecting to a database

Without preview: true, NestFactory.create() instantiates every provider, so a database module tries to connect. Keep preview mode on. Keep logger: ['error'] too: with logger: false, a failed startup exits with code 1 and prints nothing.

The build fails with BLUME_OPENAPI_UNAVAILABLE

Blume couldn't read docs-site/openapi.json. Usually the file was never generated or never committed, so a fresh clone on the docs host doesn't have it. blume dev only warns and leaves the reference out, so a working dev server can hide this. Run npm run openapi and commit the file.

Next step

Fine-tune the reference

Pick the code sample languages readers see, or turn on the Try it proxy if your API can't allow the docs origin.

Read the OpenAPI reference docs

A step here not working for you? Report a broken step.

Keep going.More guides.

Upgrade your docs with Blume.

Install today and ship a production-grade docs site in minutes. Free and open source, forever.

npx blume init