How to Migrate from Express.js to NestJS
How do I migrate from Express.js to NestJS?
TL;DR
- Bottom line: Migrate incrementally — install NestJS alongside Express using
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. - Key tool/command:
npx @nestjs/cli new my-project(scaffold) ornest generate resource users(full CRUD per-resource migration) - Watch out for: Dumping all Express middleware into NestJS middleware classes — use guards for auth, pipes for validation, and interceptors for logging/transforms instead. Also: Express 5 is now the default in NestJS 11, requiring named route wildcards (
/*splat). - Works with: NestJS 11.x (current; NestJS 12 targeting Q3 2026 brings ESM + Vitest + oxlint + Rspack), Express 5.2.x (default, shipped Dec 2025) / Express 4.x (maintenance until ~Oct 2026), Node.js 20+, TypeScript 5.x.
Constraints
- Node.js v20+ required: NestJS 11 dropped support for Node.js v16 and v18. Verify with
node -vbefore starting migration. [src3] - Named route wildcards mandatory: Express 5 requires
/*splator/{*splat}instead of/*. Unnamed wildcards throw at startup. Run@expressjs/codemodto automate this change. [src3, src7] - Never mix
@Res()with return values: Using@Res()and callingres.json()bypasses interceptors, exception filters, and serialization. Only use@Res()for streaming or file downloads. [src1, src4] - tsconfig decorators required: Set
"emitDecoratorMetadata": trueand"experimentalDecorators": true— NestJS DI silently injectsundefinedwithout them. [src2] - Multer + Express 5 compatibility:
FileInterceptoruses multer under the hood. Verify your multer version supports Express 5 before upgrading. [src3] - Query string parsing changed: Express 5 no longer uses the
qslibrary by default — nested objects and arrays in query strings break. Configureapp.set('query parser', 'extended')if needed. [src7]
Quick Reference
| 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 |
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
- Forgetting to register modules in AppModule: Unregistered modules silently fail — controllers return 404 with no error. Fix: Always add new modules to
AppModule.imports. [src1] - Using
anytypes instead of DTOs: Loses the main benefit of NestJS — type safety and automatic validation. Fix: Create a DTO class withclass-validatordecorators for every request body. [src2] - Circular dependency between modules: Two modules importing each other causes
undefinedat runtime. Fix: UseforwardRef(() => OtherModule)or restructure into a shared module. [src1] - Missing
emitDecoratorMetadatain tsconfig: NestJS DI relies on decorator metadata. Without it, injected dependencies areundefined. Fix: Set"emitDecoratorMetadata": trueand"experimentalDecorators": true. [src2] - Mixing
@Res()with return values: Using@Res()and callingres.json()bypasses interceptors, exception filters, and serialization. Fix: Return objects from controller methods; only use@Res()for streaming. [src1, src4] - Route wildcards breaking on NestJS 11 + Express 5: Express 5 requires named wildcards (
/*splat) instead of unnamed (/*). Fix: Update route patterns. Runnpx @expressjs/codemod .to automate. [src3, src7] - Not running both servers during migration: Attempting full rewrite before deploying introduces high risk. Fix: Use
ExpressAdapterto mount NestJS on the same Express instance. [src8] - Query strings breaking with Express 5: Nested objects in query parameters stop working because Express 5 dropped
qsas default parser. Fix: Addapp.set('query parser', 'extended'). [src7]
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
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| NestJS 12 (target Q3 2026) | Roadmap (next tag) | Full ESM, Vitest replaces Jest, oxlint replaces ESLint, Rspack replaces Webpack, Standard Schema (Zod/Valibot/ArkType) in route decorators, NATS v3, graceful shutdown on Express adapter | Test under next; ESM transition is low-friction (Node.js allows CJS to require() ESM). CJS project generation remains in the CLI |
| 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.2 (Dec 2025) | Current (Active) | Same v5 breaking changes (named wildcards, qs dropped, deprecated methods removed); 5.2.x is the current production-recommended release | Use /*splat, configure query parser, run @expressjs/codemod |
| Express 5.1 (Mar 2025) | Superseded | Initial latest-tag default on npm | Upgrade to 5.2.x |
| Express 4 (2014) | Maintenance (EOL target 2026-10) | — | Express TC frames Oct 2026 as a target, not a commitment |
When to Use / When Not to Use
| 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 |
Important Caveats
- NestJS uses Express as its default HTTP platform but also supports Fastify. The
@nestjs/platform-expresspackage is included by default — you get full Express compatibility including all Express middleware. - NestJS 11 ships with Express 5 as the default. If using Express 5, update route wildcards to the named syntax (
/*splat). Express 4 is still supported via explicit@nestjs/platform-expressversion pinning. - Express 5.1 became the
latesttag on npm as of March 2025. Express 4 enters maintenance-only mode with EOL no sooner than October 2026. [src7] - The
@nestjs/cligenerates boilerplate but does not migrate existing Express code automatically. Migration is a manual, route-by-route process. The@expressjs/codemodpackage automates route pattern updates but not architectural changes. - NestJS adds ~15–20% overhead compared to raw Express due to the DI container and module initialization. For most applications, this is negligible.
- Circular dependencies are the most common runtime error during migration. Use
forwardRef()as a workaround, but prefer restructuring modules to break cycles. - All Express middleware still works in NestJS via
app.use()orconsumer.apply(). You do not need to rewrite middleware on day one. - The order of
OnModuleDestroyandOnApplicationShutdownlifecycle hooks was reversed in NestJS 11. If your Express app relies on specific shutdown ordering, verify the new sequence. [src3] - NestJS 12 lands around Q3 2026 (currently on the
nextnpm tag): full ESM across all official packages, Vitest replaces Jest, oxlint replaces ESLint, Rspack replaces Webpack, and Standard Schema validation (Zod / Valibot / ArkType) becomes a first-class alternative toclass-validatorin route decorators. Migration friction is expected to be low — Node.js now lets CommonJSrequire()ESM modules — but teams mid-migration should target NestJS 11 today and re-evaluate tooling defaults once v12 is GA. [src9] - Express 5.2.x is the current production-recommended release (shipped Dec 2025). The Express Technical Committee targets Express 4 EOL no sooner than 2026-10-01, but explicitly calls these dates "goals, not commitments" — Express 4 may receive extended maintenance support given its decade-long tenure as the only major version. [src10]