Skip to content

Fix: NestJS Swagger UI Not Showing — /api-docs Returns 404 or Blank Page

FixDevs ·

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-docs route. Without it, the route doesn’t exist.
  • Setup called after app.listen() — Swagger must be set up before the app starts listening. After listen(), the route map is frozen.
  • @nestjs/swagger not installed or wrong version — the package must be installed and compatible with the NestJS version.
  • Guards or middleware blocking the docs route — a global AuthGuard or rate limiter applied before Swagger setup can block the /api-docs route.
  • Fastify adapter requires different static assets setup — the default setup works for Express; Fastify needs @fastify/static installed separately.
  • emitDecoratorMetadata disabled — without this TypeScript option, @ApiProperty() and similar decorators don’t emit type information that Swagger reads.

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 registered

Verify 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 UI

Fix 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-express

Check 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);
}

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 }).

@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().

For related NestJS issues, see Fix: NestJS ValidationPipe Not Working and Fix: NestJS Guard Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles