ExpressAdapter, convert route handlers to controllers with decorators, extract business logic into injectable services, replace middleware with guards/pipes/interceptors where appropriate, and adopt NestJS modules to organize your codebase.npx @nestjs/cli new my-project (scaffold) or nest generate resource users (full CRUD per-resource migration)/*splat).node -v before starting migration. [src3]/*splat or /{*splat} instead of /*. Unnamed wildcards throw at startup. Run @expressjs/codemod to automate this change. [src3, src7]@Res() with return values: Using @Res() and calling res.json() bypasses interceptors, exception filters, and serialization. Only use @Res() for streaming or file downloads. [src1, src4]"emitDecoratorMetadata": true and "experimentalDecorators": true — NestJS DI silently injects undefined without them. [src2]FileInterceptor uses multer under the hood. Verify your multer version supports Express 5 before upgrading. [src3]qs library by default — nested objects and arrays in query strings break. Configure app.set('query parser', 'extended') if needed. [src7]| Express Pattern | NestJS Equivalent | Example |
|---|---|---|
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-validator | class-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 |
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
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!"
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.
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.
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.
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.
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.
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.
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.
// 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);
}
}
// 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());
// 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();
// 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();
// ❌ 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;
}
}
// ✅ 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;
}
}
// ❌ 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 */ }
}
// ✅ 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 { ... }
// ❌ BAD — Rewriting the entire Express app before shipping anything
// 6 months of parallel development, no production feedback, high regression risk
// ✅ 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)
// ❌ BAD — Manual instantiation (Express habit)
@Controller('users')
export class UsersController {
private usersService = new UsersService(); // not DI-managed
@Get()
findAll() { return this.usersService.findAll(); }
}
// ✅ 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
AppModule.imports. [src1]any types instead of DTOs: Loses the main benefit of NestJS — type safety and automatic validation. Fix: Create a DTO class with class-validator decorators for every request body. [src2]undefined at runtime. Fix: Use forwardRef(() => OtherModule) or restructure into a shared module. [src1]emitDecoratorMetadata in tsconfig: NestJS DI relies on decorator metadata. Without it, injected dependencies are undefined. Fix: Set "emitDecoratorMetadata": true and "experimentalDecorators": true. [src2]@Res() with return values: Using @Res() and calling res.json() bypasses interceptors, exception filters, and serialization. Fix: Return objects from controller methods; only use @Res() for streaming. [src1, src4]/*splat) instead of unnamed (/*). Fix: Update route patterns. Run npx @expressjs/codemod . to automate. [src3, src7]ExpressAdapter to mount NestJS on the same Express instance. [src8]qs as default parser. Fix: Add app.set('query parser', 'extended'). [src7]# 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 | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| NestJS 11 (2025) | Current | Express v5 default, named wildcards (/*splat), reversed lifecycle hook order, cache-manager v6, Node.js 20+ required | Update route wildcards, check OnModuleDestroy order, verify multer version, update query parser |
| NestJS 10 (2023) | LTS | SWC as default compiler | Replace deprecated cache module import |
| NestJS 9 (2022) | Maintenance | REPL support, configurable module builder | Minor API changes |
| NestJS 8 (2021) | EOL | Standalone app API, lazy-loading modules | Upgrade to 10+ recommended |
| Express 5.1 (2025) | Current (Active) | Named route wildcards required, qs dropped, deprecated methods removed | Use /*splat, configure query parser, run @expressjs/codemod |
| Express 4 (2014) | Maintenance (EOL ~2026-10) | — | Still supported as NestJS HTTP platform |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Express codebase has >20 routes with growing complexity | Simple API with <10 endpoints, no team growth | Keep Express |
| Team needs enforced structure and conventions | Solo developer who prefers minimal frameworks | Express + custom conventions |
| Project requires WebSockets, microservices, or GraphQL | Pure REST API with no additional protocols | Express or Fastify directly |
| Hiring — NestJS developers bring consistent patterns | Legacy Express app in maintenance-only mode | Maintain Express as-is |
| Need built-in testing support (DI makes mocking easy) | Prototyping or hackathon projects | Express + Supertest |
| Enterprise project with multiple teams contributing | Serverless functions or edge workers | Hono, Cloudflare Workers |
@nestjs/platform-express package is included by default — you get full Express compatibility including all Express middleware./*splat). Express 4 is still supported via explicit @nestjs/platform-express version pinning.latest tag on npm as of March 2025. Express 4 enters maintenance-only mode with EOL no sooner than October 2026. [src7]@nestjs/cli generates boilerplate but does not migrate existing Express code automatically. Migration is a manual, route-by-route process. The @expressjs/codemod package automates route pattern updates but not architectural changes.forwardRef() as a workaround, but prefer restructuring modules to break cycles.app.use() or consumer.apply(). You do not need to rewrite middleware on day one.OnModuleDestroy and OnApplicationShutdown lifecycle hooks was reversed in NestJS 11. If your Express app relies on specific shutdown ordering, verify the new sequence. [src3]