Skip to content

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

FixDevs · (Updated: )

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

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

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:

  1. SSH to a production instance and run curl -I localhost:3000/api-docs. If it returns 404, the route is not registered.
  2. Check the bootstrap logs for “Swagger UI: http://localhost:…” — if absent, the NODE_ENV !== 'production' guard is hiding Swagger in production.
  3. Check process.env.NODE_ENV on the production instance. If it is production but you intend docs to be public, remove or invert the guard.
  4. If /api-docs-json returns 200 but paths: {}, your controllers are not registered in AppModule. Check recent commits for module imports removed during refactoring.
  5. If the route works but Swagger UI shows a blank page, swagger-ui-express is missing from the production bundle. Check that it is in dependencies, not devDependencies.

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.

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