Fix: NestJS Swagger UI Not Showing — /api-docs Returns 404 or Blank Page
Part of: JavaScript & TypeScript Errors
Quick Answer
How to fix NestJS Swagger UI not displaying — SwaggerModule setup, DocumentBuilder, decorators not appearing, guards blocking the docs route, and Fastify vs Express differences.
The Problem
NestJS Swagger UI returns a 404 or blank page:
GET /api-docs → 404 Not Found
GET /api-docs → 200 OK but empty page (no operations listed)Or the Swagger UI loads but shows no endpoints:
{
"openapi": "3.0.0",
"paths": {},
"info": { "title": "API", "version": "1.0" }
}Or decorators like @ApiProperty() aren’t reflected in the schema:
export class CreateUserDto {
@ApiProperty({ description: 'User email' })
email: string;
// email doesn't appear in Swagger UI request body schema
}Or Swagger works in development but breaks in production.
Why This Happens
NestJS Swagger relies on several pieces working together. Common failure points:
SwaggerModule.setup()not called — the setup call creates the/api-docsroute. Without it, the route doesn’t exist.- Setup called after
app.listen()— Swagger must be set up before the app starts listening. Afterlisten(), the route map is frozen. @nestjs/swaggernot installed or wrong version — the package must be installed and compatible with the NestJS version.- Guards or middleware blocking the docs route — a global
AuthGuardor rate limiter applied before Swagger setup can block the/api-docsroute. - Fastify adapter requires different static assets setup — the default setup works for Express; Fastify needs
@fastify/staticinstalled separately. emitDecoratorMetadatadisabled — without this TypeScript option,@ApiProperty()and similar decorators don’t emit type information that Swagger reads.
There is also a deployment-specific class of failure that does not show up in local testing. Swagger is often wrapped in a NODE_ENV !== 'production' check to avoid leaking internal API details publicly. When you deploy to production with NODE_ENV=production, the /api-docs route disappears — which is intentional for security. The problem is when staging also has NODE_ENV=production (a common Heroku/Render/Fly.io default), and your QA team or external API consumers expect docs to be there. They hit /api-docs, get a 404, and assume the service is broken.
The blast radius for missing Swagger in production is rarely user-facing, but it is significant for any API with external consumers. Mobile teams, partner integrations, and internal microservices all rely on the OpenAPI spec to generate clients and validate contracts. When /api-docs returns 404 in production, the discovery layer of your API is dead — new integrators cannot bootstrap, contract tests fail, and code generators break in CI. The standard mitigation is a synthetic check that hits /api-docs-json every minute and alerts if the response is not a valid OpenAPI document.
Fix 1: Correct SwaggerModule Setup
The Swagger module must be set up in main.ts before app.listen():
// main.ts — CORRECT setup
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Set up Swagger BEFORE app.listen()
const config = new DocumentBuilder()
.setTitle('My API')
.setDescription('API documentation')
.setVersion('1.0')
.addBearerAuth() // Adds Authorization header to UI
.addTag('users')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
// Swagger UI available at: http://localhost:3000/api-docs
// OpenAPI JSON at: http://localhost:3000/api-docs-json
await app.listen(3000); // AFTER SwaggerModule.setup()
}
bootstrap();WRONG — setup after listen:
// WRONG ORDER
await app.listen(3000); // Routes frozen here
SwaggerModule.setup('api-docs', app, document); // Too late — route not registeredVerify the route is accessible:
curl http://localhost:3000/api-docs-json
# Should return OpenAPI JSON with your endpoints
curl http://localhost:3000/api-docs
# Should return HTML for Swagger UIFix 2: Install Required Packages
# Install swagger packages
npm install @nestjs/swagger
# swagger-ui-express is required for Express (default NestJS adapter)
npm install swagger-ui-express
# For Fastify:
npm install @fastify/static fastify-swagger
# Verify installed versions
npm list @nestjs/swagger swagger-ui-expressCheck version compatibility:
// package.json — compatible versions (as of 2026)
{
"@nestjs/common": "^10.x",
"@nestjs/swagger": "^7.x",
"swagger-ui-express": "^5.x"
}Fix 3: Enable TypeScript Decorator Metadata
Without emitDecoratorMetadata, Swagger can’t read type information from decorators:
// tsconfig.json — required options
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true, // ← Required for @ApiProperty() to work
"strictPropertyInitialization": false
}
}After enabling, verify @ApiProperty() decorators emit type info:
export class CreateUserDto {
@ApiProperty({
description: 'User email address',
example: '[email protected]',
})
email: string;
@ApiProperty({
description: 'User age',
minimum: 0,
maximum: 120,
example: 30,
})
age: number;
@ApiPropertyOptional({ // For optional fields
description: 'User bio',
})
bio?: string;
}Fix 4: Fix Guards Blocking the Swagger Route
A global AuthGuard blocks unauthenticated access to Swagger UI. Exclude the docs route:
// main.ts — exclude Swagger from global guard
import { NestFactory, Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { APP_GUARD } from '@nestjs/core';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Option 1 — use @Public() decorator on the swagger endpoint
// (requires setting up a custom guard that checks for @Public())
// Option 2 — don't apply global guard to docs path
// Use route-level guards instead of global guards for APIs that need public docs
}Better approach — use @Public() decorator with a custom guard:
// auth/public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
// auth/jwt-auth.guard.ts
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
return super.canActivate(context);
}
}For Swagger itself — in main.ts, set up Swagger routes before applying global guards, or explicitly exclude swagger paths in middleware:
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Apply global prefix to API routes only (not swagger)
app.setGlobalPrefix('api', {
exclude: ['api-docs', 'api-docs-json', 'api-docs(.*)'],
});
// Set up Swagger
const config = new DocumentBuilder().setTitle('API').setVersion('1.0').build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document, {
swaggerOptions: {
persistAuthorization: true, // Keeps auth token between page reloads
},
});
await app.listen(3000);
}Fix 5: Fastify Adapter Setup
The default Swagger setup works with Express. Fastify requires additional configuration:
npm install @nestjs/platform-fastify @fastify/static// main.ts — Fastify adapter
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, NestFastifyApplication } from '@nestjs/platform-fastify';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
const config = new DocumentBuilder()
.setTitle('API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
await app.listen(3000, '0.0.0.0');
}
bootstrap();Fix 6: Annotate Controllers and DTOs
Swagger only shows endpoints with proper decorators. Endpoints without @ApiTags, @ApiOperation, or response decorators may still appear, but DTOs need @ApiProperty to show in request bodies:
// users.controller.ts — complete Swagger annotations
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiParam,
ApiBearerAuth,
ApiBody,
} from '@nestjs/swagger';
@ApiTags('users') // Groups endpoints under "users" in Swagger UI
@ApiBearerAuth() // Shows lock icon — requires Authorization header
@Controller('users')
export class UsersController {
@Get()
@ApiOperation({ summary: 'Get all users', description: 'Returns paginated user list' })
@ApiResponse({ status: 200, description: 'Users retrieved', type: [UserResponseDto] })
@ApiResponse({ status: 401, description: 'Unauthorized' })
findAll() {
return this.usersService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get user by ID' })
@ApiParam({ name: 'id', type: 'number', description: 'User ID' })
@ApiResponse({ status: 200, type: UserResponseDto })
@ApiResponse({ status: 404, description: 'User not found' })
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Post()
@ApiOperation({ summary: 'Create new user' })
@ApiBody({ type: CreateUserDto })
@ApiResponse({ status: 201, type: UserResponseDto })
@ApiResponse({ status: 400, description: 'Validation error' })
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
}DTO with full Swagger annotations:
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({
description: 'User email address',
example: '[email protected]',
format: 'email',
})
email: string;
@ApiProperty({
description: 'Password (min 8 chars)',
example: 'SecurePass123!',
minLength: 8,
})
password: string;
@ApiPropertyOptional({
description: 'Display name',
example: 'Alice Smith',
})
name?: string;
@ApiProperty({
description: 'User role',
enum: ['admin', 'user', 'guest'],
default: 'user',
})
role: 'admin' | 'user' | 'guest';
}Fix 7: Disable Swagger in Production
Swagger UI should typically be disabled in production (or protected):
// main.ts — conditional Swagger setup
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Only enable Swagger in non-production environments
if (process.env.NODE_ENV !== 'production') {
const config = new DocumentBuilder()
.setTitle('API')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
console.log(`Swagger UI: http://localhost:${port}/api-docs`);
}
const port = process.env.PORT || 3000;
await app.listen(port);
}Or protect it with basic auth in staging:
import * as basicAuth from 'express-basic-auth';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Protect Swagger with basic auth
app.use(
['/api-docs', '/api-docs-json'],
basicAuth({
challenge: true,
users: {
[process.env.SWAGGER_USER]: process.env.SWAGGER_PASSWORD,
},
}),
);
const config = new DocumentBuilder().setTitle('API').setVersion('1.0').build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api-docs', app, document);
await app.listen(3000);
}Fix 8: Monitor /api-docs in Production with a Synthetic Check
If your API has external consumers, treat /api-docs-json as a first-class endpoint and monitor it. A 404 on the docs route means new integrators cannot discover your API, code generators in partner CI pipelines break, and contract tests fail with confusing errors.
Synthetic check (run every 60 seconds):
#!/bin/sh
# scripts/check-swagger.sh
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" https://api.example.com/api-docs-json)
if [ "$RESPONSE" != "200" ]; then
echo "ALERT: /api-docs-json returned $RESPONSE"
exit 1
fi
# Verify the response is valid JSON with paths
PATHS=$(curl -s https://api.example.com/api-docs-json | jq '.paths | length')
if [ "$PATHS" = "0" ] || [ "$PATHS" = "null" ]; then
echo "ALERT: /api-docs-json returned 0 paths — controllers not discovered"
exit 1
fi
echo "OK: $PATHS paths exposed"Wire it into your uptime monitor. Tools like Pingdom, UptimeRobot, Datadog Synthetics, or a simple cron job that posts to a webhook on failure will catch regressions within one minute.
Pro Tip: Add a smoke test to your deploy pipeline that asserts the path count in /api-docs-json is greater than zero and matches a snapshot. This catches the silent-failure case where a refactor accidentally removes controllers from AppModule and Swagger shows an empty spec.
Production Incident Playbook: /api-docs Returns 404 After Deploy
Scenario: A partner team’s CI pipeline starts failing at 9:00 AM with “Failed to fetch OpenAPI spec from https://api.example.com/api-docs-json: 404.” Your last deploy was at 8:45 AM.
Blast radius: External API consumers cannot regenerate clients. Contract tests downstream fail. New developers onboarding to integrate with your API hit a dead end. The user-facing API still works, but the discovery layer is dead.
Detection: Synthetic check fires within one minute (if configured per Fix 8). Without it, you hear from the partner team via Slack hours later.
Diagnosis checklist:
- SSH to a production instance and run
curl -I localhost:3000/api-docs. If it returns 404, the route is not registered. - Check the bootstrap logs for “Swagger UI: http://localhost:…” — if absent, the
NODE_ENV !== 'production'guard is hiding Swagger in production. - Check
process.env.NODE_ENVon the production instance. If it isproductionbut you intend docs to be public, remove or invert the guard. - If
/api-docs-jsonreturns 200 butpaths: {}, your controllers are not registered inAppModule. Check recent commits for module imports removed during refactoring. - If the route works but Swagger UI shows a blank page,
swagger-ui-expressis missing from the production bundle. Check that it is independencies, notdevDependencies.
Recovery: The fastest path is to revert the deploy. If the cause is the NODE_ENV guard, remove it (or protect Swagger with basic auth per Fix 7) and redeploy. Recovery time should be under 15 minutes if you have synthetic checks.
Prevention: Decide explicitly whether production should expose Swagger. If yes, protect it with basic auth and monitor it. If no, document this decision and provide consumers with the OpenAPI JSON via another channel (a versioned file in S3, for example). Never let the answer be “it depends on which env var got set.”
Still Not Working?
paths: {} in the JSON output — if the OpenAPI JSON shows empty paths, your controllers aren’t being discovered. Make sure they’re imported in AppModule (or the relevant module). Controllers not registered in any module won’t appear in Swagger.
Circular dependency in DTOs — if DTO A references DTO B which references DTO A, Swagger’s schema generation may produce an empty or partial schema. Use () => RelatedDto (lazy reference) with @ApiProperty({ type: () => RelatedDto }). See Fix: NestJS Circular Dependency for the broader pattern.
@ApiHideProperty() — fields decorated with @ApiHideProperty() are intentionally excluded from Swagger. Check if this decorator was accidentally applied.
Global prefix — if you set app.setGlobalPrefix('api'), your routes become /api/users etc. Swagger’s setup path is unaffected by the global prefix, but the paths shown in the UI will include the prefix. Ensure the prefix is set before SwaggerModule.setup().
Controllers exist but aren’t in the spec — verify the controller’s module is imported in AppModule. A common refactoring mistake is moving controllers to a feature module but forgetting to add that module to AppModule.imports. See Fix: NestJS Module Not Found for the typical symptoms.
Swagger UI loads but assets are blocked by CSP — if you have a Content Security Policy header, the inline scripts Swagger UI uses can be blocked. Add 'unsafe-inline' for Swagger paths only, or serve Swagger from a subdomain without CSP restrictions.
Reverse proxy strips the /api-docs path — if you put NestJS behind nginx with a location /api/ block that rewrites paths, /api-docs may be stripped or rewritten unexpectedly. Add an explicit location /api-docs block that passes the path through unchanged.
For related NestJS issues, see Fix: NestJS ValidationPipe Not Working and Fix: NestJS Guard Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: NestJS Nest can't resolve dependencies — Provider Not Found Error
How to fix NestJS dependency injection errors — module imports, provider exports, circular dependencies, dynamic modules, and the most common 'can't resolve dependencies' patterns.
Fix: NestJS ValidationPipe Not Working — class-validator Decorators Ignored
How to fix NestJS ValidationPipe not validating requests — global pipe setup, class-transformer, whitelist and transform options, custom validators, and DTO inheritance issues.
Fix: NestJS Guard Not Working — canActivate Always Passes or Is Never Called
How to fix NestJS guards not working — applying guards globally vs controller vs method level, JWT AuthGuard, metadata with Reflector, public routes, and guard execution order.
Fix: NestJS Interceptor Not Triggered — Interceptors Not Running
How to fix NestJS interceptors not being called — global vs controller vs method binding, response transformation, async interceptors, execution context, and interceptor ordering.