All skills
Skillintermediate

Express to NestJS Migration Guide

**Use when:** - Migrating existing Express.js applications to NestJS - Modernizing legacy Node.js APIs with structured architecture - Adding TypeScript and dependency injection to Express codebases - Scaling Express applications requiring better organization - Team needs enforced architectural patterns and conventions - Application complexity justifies framework overhead

Claude Code Knowledge Pack7/10/2026

Overview

Express to NestJS Migration Guide


When to Use This Guide

Use when:

  • Migrating existing Express.js applications to NestJS
  • Modernizing legacy Node.js APIs with structured architecture
  • Adding TypeScript and dependency injection to Express codebases
  • Scaling Express applications requiring better organization
  • Team needs enforced architectural patterns and conventions
  • Application complexity justifies framework overhead

When NOT to Use:

  • Simple CRUD APIs with < 10 endpoints (Express may be sufficient)
  • Serverless functions requiring minimal cold start time
  • Prototypes or MVPs where speed > structure
  • Team lacks TypeScript experience and timeline is tight
  • Performance-critical microservices where framework overhead matters
  • Projects with unique architectural requirements conflicting with NestJS patterns

Concept Mapping: Express → NestJS

Express ConceptNestJS EquivalentKey Difference
app.get('/path', handler)@Get('/path') decoratorDeclarative vs imperative
Middleware functionsGuards, Interceptors, PipesSpecialized by purpose
req.params, req.body@Param(), @Body() decoratorsAutomatic injection
Manual require()Dependency InjectionIoC container managed
express.Router()Controller classesObject-oriented grouping
app.use(express.json())Built-in body parsingAutomatic configuration
Error handling middlewareException FiltersClass-based with inheritance
app.listen(3000)NestFactory.create()Bootstrap pattern
Custom validationclass-validator pipesDecorator-based validation
Manual service instancesProvider registrationSingleton by default

Architecture Comparison

Express Application Structure

src/
├── routes/
│   ├── users.js
│   └── posts.js
├── controllers/
│   ├── userController.js
│   └── postController.js
├── services/
│   ├── userService.js
│   └── postService.js
├── middleware/
│   ├── auth.js
│   └── errorHandler.js
└── app.js

NestJS Application Structure

src/
├── users/
│   ├── users.controller.ts
│   ├── users.service.ts
│   ├── users.module.ts
│   ├── dto/
│   │   ├── create-user.dto.ts
│   │   └── update-user.dto.ts
│   └── entities/
│       └── user.entity.ts
├── posts/
│   ├── posts.controller.ts
│   ├── posts.service.ts
│   └── posts.module.ts
├── common/
│   ├── guards/
│   ├── interceptors/
│   └── filters/
├── app.module.ts
└── main.ts

Migration Pattern: Route Handler → Controller

Before: Express Route Handler

// routes/users.js
const express = require('express');
const router = express.Router();
const UserService = require('../services/userService');

const userService = new UserService();

router.get('/', async (req, res, next) => {
  try {
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;

    const users = await userService.findAll(page, limit);
    res.json({
      success: true,
      data: users,
      page,
      limit
    });
  } catch (error) {
    next(error);
  }
});

router.get('/:id', async (req, res, next) => {
  try {
    const user = await userService.findById(req.params.id);
    if (!user) {
      return res.status(404).json({
        success: false,
        message: 'User not found'
      });
    }
    res.json({ success: true, data: user });
  } catch (error) {
    next(error);
  }
});

router.post('/', async (req, res, next) => {
  try {
    const { email, name } = req.body;

    // Manual validation
    if (!email || !name) {
      return res.status(400).json({
        success: false,
        message: 'Email and name are required'
      });
    }

    const user = await userService.create({ email, name });
    res.status(201).json({ success: true, data: user });
  } catch (error) {
    next(error);
  }
});

module.exports = router;

After: NestJS Controller

// users/dto/create-user.dto.ts

  @IsEmail()
  @IsNotEmpty()
  email: string;

  @IsString()
  @MinLength(2)
  name: string;
}

// users/dto/pagination-query.dto.ts

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number = 1;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  @Max(100)
  limit?: number = 10;
}

// users/users.controller.ts

  Controller,
  Get,
  Post,
  Body,
  Param,
  Query,
  HttpCode,
  HttpStatus,
  ParseIntPipe,
} from '@nestjs/common';

@Controller('users')

  constructor(private readonly usersService: UsersService) {}

  @Get()
  async findAll(@Query() query: PaginationQueryDto) {
    const users = await this.usersService.findAll(query.page, query.limit);
    return {
      success: true,
      data: users,
      page: query.page,
      limit: query.limit,
    };
  }

  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number) {
    const user = await this.usersService.findById(id);
    return { success: true, data: user };
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  async create(@Body() createUserDto: CreateUserDto) {
    const user = await this.usersService.create(createUserDto);
    return { success: true, data: user };
  }
}

Migration Pattern: Middleware → Guards/Interceptors

Before: Express Authentication Middleware

// middleware/auth.js
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json({
      success: false,
      message: 'No token provided'
    });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({
      success: false,
      message: 'Invalid token'
    });
  }
}

// Usage in routes
router.get('/profile', authMiddleware, async (req, res) => {
  const user = await userService.findById(req.user.id);
  res.json({ success: true, data: user });
});

After: NestJS Guard

// common/guards/jwt-auth.guard.ts

  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';

@Injectable()

  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException('No token provided');
    }

    try {
      const payload = await this.jwtService.verifyAsync(token);
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException('Invalid token');
    }

    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

// Usage in controller

@Controller('users')

  @Get('profile')
  @UseGuards(JwtAuthGuard)
  async getProfile(@Request() req) {
    return this.usersService.findById(req.user.id);
  }
}

Before: Express Logging Middleware

// middleware/logger.js
function loggerMiddleware(req, res, next) {
  const start = Date.now();

  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.path} - ${res.statusCode} - ${duration}ms`);
  });

  next();
}

// app.js
app.use(loggerMiddleware);

After: NestJS Interceptor

// common/interceptors/logging.interceptor.ts

  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Logger,
} from '@nestjs/common';

@Injectable()

  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const { method, url } = request;
    const start = Date.now();

    return next.handle().pipe(
      tap(() => {
        const response = context.switchToHttp().getResponse();
        const duration = Date.now() - start;
        this.logger.log(
          `${method} ${url} - ${response.statusCode} - ${duration}ms`,
        );
      }),
    );
  }
}

// main.ts - Apply globally

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalInterceptors(new LoggingInterceptor());
  await app.listen(3000);
}
bootstrap();

Migration Pattern: Dependency Injection

Before: Express Manual Instantiation

// services/userService.js
const UserRepository = require('../repositories/userRepository');
const EmailService = require('./emailService');

class UserService {
  constructor() {
    this.userRepository = new UserRepository();
    this.emailService = new EmailService();
  }

  async create(userData) {
    const user = await this.userRepository.create(userData);
    await this.emailService.sendWelcomeEmail(user.email);
    return user;
  }
}

module.exports = UserService;

// controllers/userController.js
const UserService = require('../services/userService');
const userService = new UserService();

async function createUser(req, res) {
  const user = await userService.create(req.body);
  res.json({ success: true, data: user });
}

After: NestJS Dependency Injection

// users/users.repository.ts

@Injectable()

  constructor(
    @InjectRepository(User)
    private readonly repository: Repository,
  ) {}

  async create(userData: Partial): Promise {
    const user = this.repository.create(userData);
    return this.repository.save(user);
  }

  async findById(id: number): Promise {
    return this.repository.findOne({ where: { id } });
  }
}

// email/email.service.ts

@Injectable()

  private readonly logger = new Logger(EmailService.name);

  async sendWelcomeEmail(email: string): Promise<void> {
    this.logger.log(`Sending welcome email to ${email}`);
    // Email sending logic
  }
}

// users/users.service.ts

@Injectable()

  constructor(
    private readonly usersRepository: UsersRepository,
    private readonly emailService: EmailService,
  ) {}

  async create(createUserDto: CreateUserDto): Promise {
    const user = await this.usersRepository.create(createUserDto);
    await this.emailService.sendWelcomeEmail(user.email);
    return user;
  }

  async findById(id: number): Promise {
    const user = await this.usersRepository.findById(id);
    if (!user) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }
    return user;
  }
}

// users/users.module.ts

@Module({
  imports: [TypeOrmModule.forFeature([User]), EmailModule],
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  exports: [UsersService],
})


Migration Pattern: Error Handling

Before: Express Error Middleware

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  console.error(err.stack);

  if (err.name === 'ValidationError') {
    return res.status(400).json({
      success: false,
      message: 'Validation failed',
      errors: err.errors
    });
  }

  if (err.name === 'UnauthorizedError') {
    return res.status(401).json({
      success: false,
      message: 'Unauthorized'
    });
  }

  res.status(500).json({
    success: false,
    message: 'Internal server error'
  });
}

// app.js
app.use(errorHandler);

After: NestJS Exception Filter

// common/filters/http-exception.filter.ts

  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';

@Catch()

  private readonly logger = new Logger(HttpExceptionFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const request = ctx.getRequest();

    let status = HttpStatus.INTERNAL_SERVER_ERROR;
    let message = 'Internal server error';
    let errors: any = undefined;

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();

      if (typeof exceptionResponse === 'object') {
        message = (exceptionResponse as any).message || message;
        errors = (exceptionResponse as any).errors;
      } else {
        message = exceptionResponse;
      }
    } else if (exception instanceof Error) {
      message = exception.message;
      this.logger.error(exception.stack);
    }

    response.status(status).json({
      success: false,
      statusCode: status,
      message,
      errors,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}

// main.ts