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 Concept | NestJS Equivalent | Key Difference |
|---|---|---|
app.get('/path', handler) | @Get('/path') decorator | Declarative vs imperative |
| Middleware functions | Guards, Interceptors, Pipes | Specialized by purpose |
req.params, req.body | @Param(), @Body() decorators | Automatic injection |
Manual require() | Dependency Injection | IoC container managed |
express.Router() | Controller classes | Object-oriented grouping |
app.use(express.json()) | Built-in body parsing | Automatic configuration |
| Error handling middleware | Exception Filters | Class-based with inheritance |
app.listen(3000) | NestFactory.create() | Bootstrap pattern |
| Custom validation | class-validator pipes | Decorator-based validation |
| Manual service instances | Provider registration | Singleton 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