How to Migrate from Express.js to NestJS

Type: Software Reference Confidence: 0.93 Sources: 8 Verified: 2026-02-23 Freshness: monthly

TL;DR

Constraints

Quick Reference

Express PatternNestJS EquivalentExample
app.get('/users', handler)@Controller('users') + @Get()@Get() findAll() { return this.usersService.findAll(); }
app.post('/users', handler)@Post() decorator@Post() create(@Body() dto: CreateUserDto) { ... }
req.params.id@Param('id') decorator@Get(':id') findOne(@Param('id') id: string)
req.query.limit@Query('limit') decorator@Get() findAll(@Query('limit') limit: number)
req.body@Body() decorator@Post() create(@Body() dto: CreateUserDto)
app.use(cors())app.enableCors()app.enableCors({ origin: 'https://example.com' })
app.use(helmet())app.use(helmet())Same — NestJS reuses Express middleware directly
app.use(authMiddleware)@UseGuards(AuthGuard)@UseGuards(JwtAuthGuard) @Get('profile') getProfile()
express.Router()@Module() + @Controller()Each feature module groups related controllers and services
app.use(express.json())Built-in (auto-enabled)Body parsing included by default in NestJS
app.use(morgan('dev'))@UseInterceptors(LoggingInterceptor)intercept(context, next) { ... return next.handle().pipe(tap(...)) }
express-validatorclass-validator + ValidationPipe@IsString() @MinLength(3) name: string; in DTO
try/catch in handler@UseFilters(HttpExceptionFilter)Exception filters catch and format errors globally
app.listen(3000)await app.listen(3000)const app = await NestFactory.create(AppModule); await app.listen(3000);
module.exports = router@Module({ controllers, providers })Modules encapsulate feature boundaries with DI

Decision Tree

START
├── Is the Express codebase in TypeScript already?
│   ├── YES → Faster migration — skip TS conversion, focus on architecture
│   └── NO → Convert to TypeScript first OR migrate to NestJS + TS simultaneously ↓
├── Is the Express app using Express 4 or Express 5?
│   ├── Express 4 → NestJS 11 supports both; can keep Express 4 as platform initially
│   └── Express 5 → Update route wildcards to /*splat syntax before migrating ↓
├── Is it a monolith with >20 route files?
│   ├── YES → Migrate module-by-module (users, auth, products, etc.)
│   └── NO ↓
├── Does the app use Express middleware for auth/validation/logging?
│   ├── YES → Replace auth with Guards, validation with Pipes, logging with Interceptors
│   └── NO ↓
├── Does the app use an ORM (Sequelize, Knex, Prisma, TypeORM)?
│   ├── YES → Wrap ORM in NestJS providers, keep existing queries
│   └── NO ↓
├── Does the app use multer for file uploads?
│   ├── YES → Verify multer version supports Express 5, then use FileInterceptor
│   └── NO ↓
├── Are there WebSocket or microservice requirements?
│   ├── YES → Use NestJS Gateways and Microservices module
│   └── NO ↓
└── DEFAULT → Scaffold NestJS project, migrate route-by-route into controllers + services

Step-by-Step Guide

1. Scaffold a new NestJS project alongside Express

Create a fresh NestJS project. You can run it in parallel with your Express app during migration. [src1, src2]

npm i -g @nestjs/cli
nest new my-nestjs-app
cd my-nestjs-app
npm run start:dev

Verify: curl http://localhost:3000 → returns "Hello World!"

2. Convert Express route handlers to NestJS controllers

Take an Express route group and create the equivalent NestJS controller. Each app.get(), app.post() becomes a decorated method. [src2, src4]

// BEFORE: Express routes/users.js
const router = express.Router();
router.get('/', async (req, res) => {
  const users = await db.query('SELECT * FROM users');
  res.json(users);
});

// AFTER: NestJS users.controller.ts
import { Controller, Get, Post, Param, Body, NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Get(':id')
  async findOne(@Param('id') id: string) {
    const user = await this.usersService.findOne(id);
    if (!user) throw new NotFoundException('User not found');
    return user;
  }

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}

Verify: curl http://localhost:3000/users returns data.

3. Extract business logic into NestJS services

Move database queries and business logic out of controllers into injectable services. Controllers become thin, services are testable and reusable. [src4, src5]

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  findAll(): Promise<User[]> {
    return this.usersRepository.find();
  }

  findOne(id: string): Promise<User | null> {
    return this.usersRepository.findOneBy({ id });
  }
}

Verify: npm run test -- --testPathPattern=users.service passes.

4. Group related components into NestJS modules

Every feature gets its own module that declares controllers, providers, and imports. This replaces Express's flat routes directory. [src1, src2]

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

Verify: App compiles and all routes under /users work.

5. Replace Express middleware with guards, pipes, and interceptors

Express middleware handles everything. NestJS splits these into purpose-built constructs: guards for auth, pipes for validation, interceptors for logging. [src4, src6]

// Auth guard replaces Express auth middleware
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.split(' ')[1];
    if (!token) throw new UnauthorizedException();
    request.user = this.jwtService.verify(token);
    return true;
  }
}

// Apply per-controller or globally
@UseGuards(JwtAuthGuard)
@Controller('users')
export class UsersController { ... }

Verify: curl -H "Authorization: Bearer invalid" http://localhost:3000/users → 401.

6. Migrate validation to DTOs + ValidationPipe

Replace express-validator with class-validator decorators and the global ValidationPipe. [src1, src5]

// DTO with class-validator decorators
import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(3)
  name: string;

  @IsEmail()
  email: string;
}

// main.ts — enable globally
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: true,
  transform: true,
}));

Verify: POST with invalid data returns 400 with validation errors.

7. Migrate error handling to exception filters

Replace Express app.use((err, req, res, next)) error handlers with NestJS exception filters. [src1, src4]

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    const status = exception instanceof HttpException
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;

    response.status(status).json({
      statusCode: status,
      message: exception instanceof HttpException
        ? exception.getResponse()
        : 'Internal Server Error',
      timestamp: new Date().toISOString(),
    });
  }
}

// main.ts
app.useGlobalFilters(new AllExceptionsFilter());

Verify: Throw an error in any controller — the filter catches and formats it.

8. Update route wildcards for Express 5 compatibility

If migrating to NestJS 11 with Express 5 (the new default), update all wildcard route patterns. Express 5 requires named wildcards. Use the official codemod to automate most changes. [src3, src7]

# Run the official Express codemod
npx @expressjs/codemod .
// BEFORE: Express 4 unnamed wildcards
app.get('/files/*', serveFiles);

// AFTER: Express 5 / NestJS 11 named wildcards
@Get('files/*splat')
serveFiles(@Param('splat') path: string) { ... }

Verify: npx nest info shows NestJS 11.x. All routes load without startup errors.

Code Examples

TypeScript: Complete Express-to-NestJS controller migration

// Input:  Express route file with CRUD operations, middleware, and validation
// Output: Equivalent NestJS module with controller, service, DTO, and guard

// products/dto/create-product.dto.ts
import { IsString, IsNumber, IsOptional, Min } from 'class-validator';

export class CreateProductDto {
  @IsString()
  name: string;

  @IsNumber()
  @Min(0)
  price: number;

  @IsString()
  @IsOptional()
  description?: string;
}

// products/products.controller.ts
import { Controller, Get, Post, Put, Delete, Param, Body, UseGuards, Query } from '@nestjs/common';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';

@Controller('products')
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  @Get()
  findAll(@Query('page') page = 1, @Query('limit') limit = 20) {
    return this.productsService.findAll(page, limit);
  }

  @UseGuards(JwtAuthGuard)
  @Post()
  create(@Body() dto: CreateProductDto) {
    return this.productsService.create(dto);
  }

  @UseGuards(JwtAuthGuard)
  @Put(':id')
  update(@Param('id') id: string, @Body() dto: CreateProductDto) {
    return this.productsService.update(id, dto);
  }

  @UseGuards(JwtAuthGuard)
  @Delete(':id')
  remove(@Param('id') id: string) {
    return this.productsService.remove(id);
  }
}

TypeScript: Migrating Express middleware to NestJS interceptor

// Input:  Express response-time + logging middleware
// Output: NestJS interceptor with execution context and RxJS observable

import { Injectable, NestInterceptor, ExecutionContext, CallHandler, Logger } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

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

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

// Apply globally in main.ts:
// app.useGlobalInterceptors(new LoggingInterceptor());

TypeScript: Running Express and NestJS side-by-side during migration

// Input:  Existing Express app that needs gradual migration
// Output: NestJS app mounted alongside Express using the Express adapter

import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import * as express from 'express';
import { AppModule } from './app.module';

async function bootstrap() {
  const server = express();

  // Legacy Express routes still work
  server.get('/legacy/health', (req, res) => {
    res.json({ status: 'ok', framework: 'express' });
  });

  // Mount NestJS on the same Express instance
  const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
  await app.init();

  // NestJS routes coexist with legacy routes
  server.listen(3000, () => {
    console.log('Hybrid Express + NestJS server running on port 3000');
  });
}

bootstrap();

TypeScript: Configuring Express 5 query parser in NestJS 11

// Input:  NestJS 11 app that needs nested query string support
// Output: NestJS bootstrap with explicit query parser configuration

import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule);

  // Express 5 dropped qs by default — restore nested query support
  app.set('query parser', 'extended');

  // Enable global validation
  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }));

  await app.listen(3000);
}

bootstrap();

Anti-Patterns

Wrong: Putting all business logic in NestJS controllers

// ❌ BAD — Controller does everything, same problem as Express route handlers
@Controller('orders')
export class OrdersController {
  @Post()
  async create(@Body() body: any) {
    const user = await db.query('SELECT * FROM users WHERE id = $1', [body.userId]);
    if (!user) throw new NotFoundException();
    const order = await db.query('INSERT INTO orders ...', [body]);
    await emailService.send(user.email, 'Order confirmed');
    return order;
  }
}

Correct: Thin controllers, fat services

// ✅ GOOD — Controller delegates to service, service is testable and reusable
@Controller('orders')
export class OrdersController {
  constructor(private readonly ordersService: OrdersService) {}

  @Post()
  create(@Body() dto: CreateOrderDto) {
    return this.ordersService.create(dto);
  }
}

@Injectable()
export class OrdersService {
  constructor(
    private readonly usersService: UsersService,
    private readonly emailService: EmailService,
  ) {}

  async create(dto: CreateOrderDto): Promise<Order> {
    const user = await this.usersService.findOneOrFail(dto.userId);
    const order = await this.ordersRepository.save(dto);
    await this.emailService.send(user.email, 'Order confirmed');
    return order;
  }
}

Wrong: Using NestJS middleware for everything (just like Express)

// ❌ BAD — Treating NestJS middleware the same as Express middleware
@Injectable()
export class AuthMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) { /* auth */ }
}
@Injectable()
export class ValidationMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) { /* validation */ }
}
@Injectable()
export class LoggingMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction) { /* logging */ }
}

Correct: Use the right NestJS construct for each concern

// ✅ GOOD — Guards for auth, Pipes for validation, Interceptors for logging
@UseGuards(JwtAuthGuard)              // Auth → Guard
@UsePipes(ValidationPipe)             // Validation → Pipe
@UseInterceptors(LoggingInterceptor)  // Logging → Interceptor
@Controller('orders')
export class OrdersController { ... }

Wrong: Migrating everything at once (big-bang rewrite)

// ❌ BAD — Rewriting the entire Express app before shipping anything
// 6 months of parallel development, no production feedback, high regression risk

Correct: Incremental migration with hybrid server

// ✅ GOOD — Express and NestJS coexist on the same server
const server = express();
server.use('/api/v1/legacy', legacyRouter);  // Express routes still work
const app = await NestFactory.create(AppModule, new ExpressAdapter(server));
await app.init();
// /api/v1/users → NestJS (migrated)
// /api/v1/legacy/reports → Express (not yet migrated)

Wrong: Not using dependency injection

// ❌ BAD — Manual instantiation (Express habit)
@Controller('users')
export class UsersController {
  private usersService = new UsersService();  // not DI-managed

  @Get()
  findAll() { return this.usersService.findAll(); }
}

Correct: Let NestJS DI container manage instances

// ✅ GOOD — Constructor injection, testable, lifecycle-managed
@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll() { return this.usersService.findAll(); }
}
// UsersService declared as provider in UsersModule

Common Pitfalls

Diagnostic Commands

# Scaffold a new NestJS project
npx @nestjs/cli new my-project

# Generate a full CRUD resource (controller + service + module + DTOs)
nest generate resource users

# Check NestJS version and environment info
npx nest info

# Run in debug mode to see DI container resolution
nest start --debug --watch

# Run all unit tests
npm run test

# Run end-to-end tests
npm run test:e2e

# Check for circular dependencies (NestJS warns in console)
# Look for: "A circular dependency has been detected"

# Run Express codemod to update route patterns for Express 5
npx @expressjs/codemod .

# Verify Node.js version meets NestJS 11 requirement (v20+)
node -v

Version History & Compatibility

VersionStatusBreaking ChangesMigration Notes
NestJS 11 (2025)CurrentExpress v5 default, named wildcards (/*splat), reversed lifecycle hook order, cache-manager v6, Node.js 20+ requiredUpdate route wildcards, check OnModuleDestroy order, verify multer version, update query parser
NestJS 10 (2023)LTSSWC as default compilerReplace deprecated cache module import
NestJS 9 (2022)MaintenanceREPL support, configurable module builderMinor API changes
NestJS 8 (2021)EOLStandalone app API, lazy-loading modulesUpgrade to 10+ recommended
Express 5.1 (2025)Current (Active)Named route wildcards required, qs dropped, deprecated methods removedUse /*splat, configure query parser, run @expressjs/codemod
Express 4 (2014)Maintenance (EOL ~2026-10)Still supported as NestJS HTTP platform

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Express codebase has >20 routes with growing complexitySimple API with <10 endpoints, no team growthKeep Express
Team needs enforced structure and conventionsSolo developer who prefers minimal frameworksExpress + custom conventions
Project requires WebSockets, microservices, or GraphQLPure REST API with no additional protocolsExpress or Fastify directly
Hiring — NestJS developers bring consistent patternsLegacy Express app in maintenance-only modeMaintain Express as-is
Need built-in testing support (DI makes mocking easy)Prototyping or hackathon projectsExpress + Supertest
Enterprise project with multiple teams contributingServerless functions or edge workersHono, Cloudflare Workers

Important Caveats

Related Units