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:
- @nestjs/swagger — generates the OpenAPI spec from controllers and DTOs.
- @scalar/nestjs-api-reference — renders the Scalar UI with that spec.
The default Swagger UI is disabled; only Scalar is displayed.
Where it lives in the project
| 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.) |
Decorators to hide or conditionally expose endpoints
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.
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;
}
Reusable Zod schemas
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:
import { booleanSchema, LIST_QUERY_SCHEMA } from '@/core/schemas';
protected get schema() {
return LIST_QUERY_SCHEMA.and(
z.object({ active: booleanSchema() }),
);
}
Activation on bootstrap
In src/host/main.ts, after creating the application:
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:
http://localhost:3000/doc
The port follows the PORT variable from .env.
Main configuration
All Scalar customization starts in define-documentation.ts:
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',
],
}),
);
}
DocumentBuilder (API metadata)
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:
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.
Documentation endpoint
The template uses /doc by default (DOC_ENDPOINT in define-documentation.ts). To change the URL:
const docEndpoint = '/doc'; // template default
// const docEndpoint = '/api-docs'; // example alternate URL
Register the same value in SwaggerModule.setup and in app.use.
Scalar options
| 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.
Disabling Swagger UI
SwaggerModule.setup(docEndpoint, app, document, { swaggerUiEnabled: false });
SwaggerModule is still required to generate the OpenAPI document — only the default Swagger interface is turned off.
How endpoints appear in the spec
Scalar does not declare routes manually. Everything comes from controllers registered in Nest modules and Swagger decorators on them.
Tags and route prefix
Koala Nest's @Controller decorator applies route and tag automatically:
// 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:
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.
Status and response type
Document each operation on the controller:
@Post()
@ApiCreatedResponse({ type: CreatePersonResponse })
@HttpCode(HttpStatus.CREATED)
handle(@Body() request: CreatePersonRequest): Promise<CreatePersonResponse> {
return this.handler.handle(request);
}
@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.
Query params in listings
Scalar infers query params from the class used in @Query():
@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.
DTO schemas
Requests and responses must use @ApiProperty() on each exposed field:
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
exampleto facilitate "Try it" in Scalar; - use
typeorisArray: truefor 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.
New resource in documentation
When creating a resource, documentation updates automatically if you:
- Create
router.config.tswith tag and prefix. - Use
@Controller(RESOURCE_ROUTER_CONFIG)on each controller. - Decorate requests/responses with
@ApiProperty(). - Decorate HTTP handlers with
@ApiOkResponse,@ApiCreatedResponse, etc. - Register controllers in the Nest module (
<Resource>Module).
There is no need to edit define-documentation.ts per resource.
Common customizations
Change title and description
Edit the DocumentBuilder in define-documentation.ts. The version can continue coming from package.json or be fixed.
Change the URL from /doc
Change docEndpoint and restart the server.
Display models/schemas
apiReference({
// ...
hideModels: false,
});
Enable OpenAPI download
apiReference({
// ...
hideDownloadButton: false,
});
Add staging/production environment
.addServer(process.env.API_URL ?? 'http://localhost:3000', 'API')
Define API_URL in envSchema if you want to configure per environment.
Automatic Scalar authentication
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.
What the template already does
| 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).
JWT — Scalar password flow
Scalar shows the JWT scheme with a password flow:
username→ user email (User.email)password→ user passwordtokenUrl→POST /auth/loginrefreshUrl→POST /auth/refreshx-tokenName→accessToken(Scalar sends Bearer automatically)
In develop, sample credentials are pre-filled (admin@example.com / admin123). In production, fields stay empty.
Usage at /doc:
- Open
/doc - Click Authenticate
- Select JWT, confirm
username/password, and authorize - Use Try it on protected endpoints
OAuth2 — Scalar authorization code flow
For each provider in OAUTH2_PROVIDERS, the template registers a scheme (e.g. auth0):
authorizationUrl— full link fromOAuth2AuthService.authLink()tokenUrl—POST /oauth2/scalar-token(exchangescode, issues JWT, returns{ accessToken, refreshToken })x-scalar-client-id,x-scalar-redirect-uri,x-scalar-security-body— Scalar extensions for the authorization code flowx-tokenName: accessToken
Usage at /doc:
- Authenticate → select the provider (e.g. auth0)
- Complete login at the provider
- Scalar exchanges the code, receives the JWT, and applies it to requests
Central configuration (define-documentation.ts)
Logic lives in buildDocAuthorizations(app) inside define-documentation.ts:
// 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.
import { IsPublic } from '@/host/decorators/is-public.decorator';
@Post('token')
@IsPublic()
handle() { ... }
Scalar extensions (OAuth2)
| 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.
clientSecret and test credentials are pre-filled only in develop.
Quick verification
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?
Related reading
- Controllers — thin HTTP entry pattern
- Routes and tags —
RouterConfigBaseand Scalar grouping - Authentication — JWT, guards, and OAuth2
- Requests and responses — DTOs with Swagger and
@AutoMap()