OpenAPI documentation with Scalar

How to configure and customize interactive documentation at /doc with Scalar (OpenAPI via @nestjs/swagger).

Projects generated by Koala Nest expose interactive documentation at /doc. The flow has two steps:

  1. @nestjs/swagger — generates the OpenAPI spec from controllers and DTOs.
  2. @scalar/nestjs-api-reference — renders the Scalar UI with that spec.

The default Swagger UI is disabled; only Scalar is displayed.

File Function
src/host/open-api/define-documentation.ts Builds the spec and registers Scalar
src/host/main.ts Calls defineDocumentation(app) on bootstrap
src/host/decorators/controller.decorator.ts Applies Nest route + Swagger tag
src/host/decorators/api-exclude-endpoint-diff-develop.decorator.ts Hides endpoint outside develop
src/host/decorators/api-property-only-develop.decorator.ts Documents DTO property only in develop
src/host/decorators/api-property-enum.decorator.ts Documents numeric enums in Swagger
src/host/decorators/scalar-token-endpoint.decorator.ts Composes Scalar token route decorators
Requests/responses in src/application/ Schemas documented with @ApiProperty()
src/core/schemas/ Reusable Zod helpers (booleanSchema, nativeEnumSchema, etc.)
typescript
import { ApiExcludeEndpointDiffDevelop } from '@/host/decorators/api-exclude-endpoint-diff-develop.decorator';

@Delete(':id')
@ApiExcludeEndpointDiffDevelop()
handle(@Param('id') id: string) { ... }

Outside NODE_ENV=develop, the endpoint is omitted from Scalar. Use @ApiPropertyOnlyDevelop() on internal DTOs and @ApiPropertyEnum({ enum: MyEnum }) for numeric enums.

typescript
import { ApiPropertyOnlyDevelop } from '@/host/decorators/api-property-only-develop.decorator';
import { ApiPropertyEnum } from '@/host/decorators/api-property-enum.decorator';

export class MyRequest {
  @ApiPropertyOnlyDevelop({ example: 'internal-only' })
  debugField?: string;

  @ApiPropertyEnum({ enum: StatusEnum })
  status: StatusEnum;
}

In src/core/schemas/:

Helper Use
booleanSchema() Query booleans (?active=true)
nativeEnumSchema(enum) Numeric enums in query/body
emailSchema(value, required?) Email validation
documentNumberSchema(value) CPF/CNPJ, including alphanumeric CNPJ (via @koalarx/utils)
setMaskDocumentNumber(value) Document mask (via @koalarx/utils/KlString)
LIST_QUERY_SCHEMA Pagination and sorting

Example in validators:

typescript
import { booleanSchema, LIST_QUERY_SCHEMA } from '@/core/schemas';

protected get schema() {
  return LIST_QUERY_SCHEMA.and(
    z.object({ active: booleanSchema() }),
  );
}

In src/host/main.ts, after creating the application:

typescript
import { defineDocumentation } from './open-api/define-documentation';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // applyHttpMiddleware(app) — see HTTP middleware guide

  defineDocumentation(app);

  await app.listen(process.env.PORT || 3000);
}

With the server running:

text
http://localhost:3000/doc

The port follows the PORT variable from .env.

All Scalar customization starts in define-documentation.ts:

typescript
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import packageJson from '../../../package.json';
import { apiReference } from '@scalar/nestjs-api-reference';

export function defineDocumentation(app: INestApplication) {
  const documentBuilder = new DocumentBuilder()
    .setTitle(packageJson.name)
    .setVersion(packageJson.version)
    .build();

  const document = SwaggerModule.createDocument(app, documentBuilder);
  const docEndpoint = '/doc';

  SwaggerModule.setup(docEndpoint, app, document, { swaggerUiEnabled: false });

  app.use(
    docEndpoint,
    apiReference({
      spec: { content: document },
      metaData: {
        title: packageJson.name,
        version: packageJson.version,
      },
      hideModels: true,
      hideDownloadButton: true,
      hideClientButton: true,
      hiddenClients: [
        'libcurl',
        'clj_http',
        'restsharp',
        'native',
        'http1.1',
        'asynchttp',
        'nethttp',
        'okhttp',
        'unirest',
        'xhr',
        'request',
        'nsurlsession',
        'cohttp',
        'guzzle',
        'http1',
        'http2',
        'webrequest',
        'restmethod',
        'requests',
        'httr',
        'httpie',
        'wget',
        'undici',
      ],
    }),
  );
}

Use NestJS Swagger's DocumentBuilder to define the spec header:

Method Usage
setTitle() Name displayed in Scalar (default: package.json name)
setDescription() Introductory API text (optional)
setVersion() Spec version (default: package.json)

Extension examples:

typescript
const documentBuilder = new DocumentBuilder()
  .setTitle('My API')
  .setDescription('Internal API for product X')
  .setVersion('1.2.0')
  .addServer('http://localhost:3000', 'Development')
  .addServer('https://api.example.com', 'Production')
  .build();

When authentication is installed, define-documentation.ts applies global .addBearerAuth() and .addSecurityRequirements('bearer'). Routes with @IsPublic() have no security in the spec — same rule as the global AuthGuard.

The template uses /doc by default (DOC_ENDPOINT in define-documentation.ts). To change the URL:

typescript
const docEndpoint = '/doc'; // template default
// const docEndpoint = '/api-docs'; // example alternate URL

Register the same value in SwaggerModule.setup and in app.use.

Option Template value Effect
spec.content document generated by Swagger Feeds the UI with OpenAPI
metaData title, description, version Header displayed in Scalar
hideModels true Hides schemas/models panel
hideDownloadButton true Removes spec download button
hideClientButton true Removes HTTP client selector
hiddenClients client list Hides code generators (curl, fetch, etc.)

To display models or the download button, pass false on the corresponding flags.

The template hides HTTP clients because documentation is aimed at testing endpoints in the UI itself, not generating snippets in other languages.

typescript
SwaggerModule.setup(docEndpoint, app, document, { swaggerUiEnabled: false });

SwaggerModule is still required to generate the OpenAPI document — only the default Swagger interface is turned off.

Scalar does not declare routes manually. Everything comes from controllers registered in Nest modules and Swagger decorators on them.

Koala Nest's @Controller decorator applies route and tag automatically:

typescript
// src/host/decorators/controller.decorator.ts
export function Controller(config: RouterConfigBase) {
  return function (target: any) {
    NestController(config.group)(target);
    ApiTags(config.tag)(target);
  };
}

Resource configuration:

typescript
class PersonRouterConfig extends RouterConfigBase {
  constructor() {
    super('Person', '/person'); // tag, prefix
  }
}

export const PERSON_ROUTER_CONFIG = new PersonRouterConfig();

All Person controllers use @Controller(PERSON_ROUTER_CONFIG) and appear grouped under the Person tag at /doc.

Document each operation on the controller:

typescript
@Post()
@ApiCreatedResponse({ type: CreatePersonResponse })
@HttpCode(HttpStatus.CREATED)
handle(@Body() request: CreatePersonRequest): Promise<CreatePersonResponse> {
  return this.handler.handle(request);
}
typescript
@Get(':id')
@ApiOkResponse({ type: ReadPersonResponse })
async handle(@Param('id') id: string): Promise<ReadPersonResponse> {
  return await this.handler.handle(+id);
}

For empty responses (update/delete), @ApiOkResponse() without type is sufficient.

Scalar infers query params from the class used in @Query():

typescript
@Get()
@ApiOkResponse({ type: ReadManyPersonResponse })
async handle(@Query() query: ReadManyPersonRequest): Promise<ReadManyPersonResponse> {
  return await this.handler.handle(query);
}

Properties of ReadManyPersonRequest (including those inherited from PaginationRequest) appear as query parameters in the documentation.

Requests and responses must use @ApiProperty() on each exposed field:

typescript
export class CreatePersonRequest extends ObjectClass<CreatePersonRequest> {
  @ApiProperty({ example: 'John Doe' })
  @AutoMap()
  name: string;

  @ApiProperty({ type: CreatePersonAddressRequest })
  @AutoMap()
  address: CreatePersonAddressRequest;

  @ApiProperty({ type: CreatePersonContactRequest, isArray: true })
  @AutoMap({ type: () => CreatePersonContactRequest })
  contacts: CreatePersonContactRequest[];
}

Best practices:

  • use example to facilitate "Try it" in Scalar;
  • use type or isArray: true for nested objects and lists;
  • for optional fields, required: false (default for query params);
  • keep @ApiProperty() aligned with what the Zod validator actually accepts.

Domain entities (src/domain/entities/) do not need @ApiProperty() — only input/output DTOs in the application layer.

When creating a resource, documentation updates automatically if you:

  1. Create router.config.ts with tag and prefix.
  2. Use @Controller(RESOURCE_ROUTER_CONFIG) on each controller.
  3. Decorate requests/responses with @ApiProperty().
  4. Decorate HTTP handlers with @ApiOkResponse, @ApiCreatedResponse, etc.
  5. Register controllers in the Nest module (<Resource>Module).

There is no need to edit define-documentation.ts per resource.

Edit the DocumentBuilder in define-documentation.ts. The version can continue coming from package.json or be fixed.

Change docEndpoint and restart the server.

typescript
apiReference({
  // ...
  hideModels: false,
});
typescript
apiReference({
  // ...
  hideDownloadButton: false,
});
typescript
.addServer(process.env.API_URL ?? 'http://localhost:3000', 'API')

Define API_URL in envSchema if you want to configure per environment.

When the authentication module is installed, the template configures Scalar to obtain the JWT from the UI itself — no manual token copy. OAuth2 security schemes are embedded in the OpenAPI document via define-documentation.ts.

File / route Role
src/host/open-api/define-documentation.ts Builds OAuth2 schemes in OpenAPI and configures Scalar when IJwtTokenService is registered
POST /auth/login Password flow used by Scalar (JWT)
POST /oauth2/scalar-token Authorization code flow used by Scalar (OAuth2)
GET /sso/callback OAuth callback for Scalar (code + state in query)

Helper endpoints are hidden outside develop (@ApiExcludeEndpointDiffDevelop).

Scalar shows the JWT scheme with a password flow:

  • username → user email (User.email)
  • password → user password
  • tokenUrlPOST /auth/login
  • refreshUrlPOST /auth/refresh
  • x-tokenNameaccessToken (Scalar sends Bearer automatically)

In develop, sample credentials are pre-filled (admin@example.com / admin123). In production, fields stay empty.

Usage at /doc:

  1. Open /doc
  2. Click Authenticate
  3. Select JWT, confirm username/password, and authorize
  4. Use Try it on protected endpoints

For each provider in OAUTH2_PROVIDERS, the template registers a scheme (e.g. auth0):

  • authorizationUrl — full link from OAuth2AuthService.authLink()
  • tokenUrlPOST /oauth2/scalar-token (exchanges code, issues JWT, returns { accessToken, refreshToken })
  • x-scalar-client-id, x-scalar-redirect-uri, x-scalar-security-body — Scalar extensions for the authorization code flow
  • x-tokenName: accessToken

Usage at /doc:

  1. Authenticate → select the provider (e.g. auth0)
  2. Complete login at the provider
  3. Scalar exchanges the code, receives the JWT, and applies it to requests

Logic lives in buildDocAuthorizations(app) inside define-documentation.ts:

typescript
// JWT scheme (password → /auth/login) when LoginController exists
// OAuth2 provider schemes (authorization code → /oauth2/scalar-token)

documentBuilder.addBearerAuth(authorization.config, authorization.name);
syncIsPublicRoutesInOpenApi(document);
applyAuthSecurityToProtectedRoutes(document, schemeNames);

Routes with @IsPublic() get security: [] in the spec; other operations inherit the registered schemes.

typescript
import { IsPublic } from '@/host/decorators/is-public.decorator';

@Post('token')
@IsPublic()
handle() { ... }
Extension Purpose
x-tokenName Response field used as Bearer (accessToken)
x-scalar-client-id Pre-fills Client ID
x-scalar-redirect-uri Authorization code callback URI
x-scalar-security-body Extra tokenUrl body (e.g. provider)

Reference: Scalar — Authentication.

If an endpoint does not appear at /doc:

  • is the controller registered in the Nest module's controllers?
  • is the module imported in AppModule?
  • is the HTTP method (@Get, @Post, etc.) on the controller?
  • did the application restart after changes?

If a field schema is incomplete:

  • is @ApiProperty() missing on the property?
  • do nested objects need type: ChildClass?
  • do arrays need isArray: true?

Koala Nest

A facilitator for building NestJS APIs with DDD architecture. Code copied into your repository — readable, adaptable, and under your control.

Creator

igordrangel.com.br

Design, back-end, and product strategy.

Quick Commands

Global CLI and scripts in the generated project

  • bun install -g @koalarx/nest
  • kl-nest new
  • kl-nest add cache
  • bun run migration:run # CRUD template
  • kl-nest --help
© 2026 Koala NestBuilt for NestJS developers and AI-assisted workflows.