← Back to DevBytes

Migrating from Express to NestJS: Step-by-Step Guide

Understanding the Migration Landscape

Express has been the undisputed king of Node.js web frameworks for over a decade. Its minimalist philosophy, lightning-fast setup, and massive ecosystem made it the default choice for backend applications. However, as applications grow in complexity, the lack of architectural guidance in Express can lead to tangled codebases that are difficult to maintain, test, and scale. NestJS emerges as the natural evolution path—a framework that preserves the Node.js/Express runtime while introducing a disciplined, TypeScript-first architecture inspired by Angular. This guide walks you through migrating an Express application to NestJS in a systematic, incremental manner that minimizes risk and downtime.

Why Migrate from Express to NestJS?

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before diving into the technical steps, understanding the concrete benefits clarifies why this migration is worth the effort:

Key Architectural Differences to Internalize

Before writing migration code, grasp the fundamental paradigm shift. Express operates on a middleware pipeline where functions execute sequentially on the request/response objects. NestJS organizes code into discrete, composable building blocks:

Think of the migration not as a rewrite but as a reorganization where existing Express route handlers and middleware are transplanted into NestJS constructs with clearer contracts.

Step-by-Step Migration Guide

The safest approach is an incremental migration where Express and NestJS coexist within the same project, allowing you to move routes module by module while maintaining a single deployable artifact. Here's the complete roadmap.

Step 1: Initialize the NestJS Project Inside Your Express Codebase

Begin by installing the NestJS CLI globally and generating a new project structure alongside your existing Express code. The goal is to create a NestJS scaffold that can import and gradually replace Express functionality.

npm install -g @nestjs/cli
nest new server --directory ./nestjs-app
cd nestjs-app

This creates a standard NestJS project with app.module.ts, app.controller.ts, and app.service.ts. Rather than discarding your Express code, you will keep both codebases in the same repository and let NestJS serve as the HTTP entry point, delegating to Express routes during the transition period.

Step 2: Configure NestJS to Host Existing Express Routes as Middleware

NestJS allows you to mount raw Express middleware and routers directly. This is the bridge that lets you migrate route by route without downtime. First, install the Express platform adapter if not already present:

npm install @nestjs/platform-express

Create a dedicated module that wraps your existing Express router. Suppose your current Express application defines routes in a file called legacy-routes.js:

// legacy-routes.js (existing Express code)
const express = require('express');
const router = express.Router();

router.get('/api/users', (req, res) => {
  const users = [{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }];
  res.json(users);
});

router.post('/api/users', (req, res) => {
  const { name } = req.body;
  res.status(201).json({ id: Date.now(), name });
});

router.get('/api/health', (req, res) => {
  res.json({ status: 'ok', timestamp: Date.now() });
});

module.exports = router;

In NestJS, create a module that imports this router using the MiddlewareConsumer interface. Here's how to bridge the gap:

// legacy.module.ts
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import * as legacyRouter from '../legacy-routes';

@Module({})
export class LegacyModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer
      .apply(legacyRouter)
      .forRoutes('*'); // Mounts the Express router on all paths
  }
}

Register this module in your root AppModule so NestJS passes requests through to the legacy router:

// app.module.ts
import { Module } from '@nestjs/common';
import { LegacyModule } from './legacy/legacy.module';
import { UsersModule } from './users/users.module'; // New NestJS module (migrated later)

@Module({
  imports: [LegacyModule, UsersModule],
})
export class AppModule {}

At this point, your NestJS application handles all requests—those not yet migrated pass through the Express router, while new NestJS routes coexist. The application starts via NestJS's bootstrap:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
  console.log('Server running on http://localhost:3000');
}
bootstrap();

Step 3: Migrate Route Handlers to NestJS Controllers

Now begin migrating routes one by one. Pick a self-contained route group—users in this example—and create a dedicated NestJS module with controller and service.

First, generate the module using the CLI (or manually):

nest generate module users
nest generate controller users
nest generate service users

The service contains the business logic that was previously inline in Express route handlers:

// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';

interface User {
  id: number;
  name: string;
  email: string;
}

@Injectable()
export class UsersService {
  private users: User[] = [
    { id: 1, name: 'Alice', email: 'alice@example.com' },
    { id: 2, name: 'Bob', email: 'bob@example.com' },
  ];

  findAll(): User[] {
    return this.users;
  }

  findOne(id: number): User {
    const user = this.users.find(u => u.id === id);
    if (!user) {
      throw new NotFoundException(`User with id ${id} not found`);
    }
    return user;
  }

  create(createUserDto: CreateUserDto): User {
    const newUser: User = {
      id: this.users.length + 1,
      name: createUserDto.name,
      email: createUserDto.email,
    };
    this.users.push(newUser);
    return newUser;
  }

  update(id: number, updateUserDto: UpdateUserDto): User {
    const user = this.findOne(id);
    Object.assign(user, updateUserDto);
    return user;
  }

  remove(id: number): void {
    const index = this.users.findIndex(u => u.id === id);
    if (index === -1) {
      throw new NotFoundException(`User with id ${id} not found`);
    }
    this.users.splice(index, 1);
  }
}

The DTOs define and validate request payloads. In Express, validation was either absent or handled manually. NestJS uses class-validator decorators:

// create-user.dto.ts
import { IsString, IsEmail, MinLength, IsNotEmpty } from 'class-validator';

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

  @IsEmail()
  @IsNotEmpty()
  email: string;
}
// update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(CreateUserDto) {}

The controller replaces Express route handlers. Notice how decorators replace router.get() and manual path definitions:

// users.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Param,
  Put,
  Delete,
  ParseIntPipe,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';

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

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

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }

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

  @Put(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return this.usersService.update(id, updateUserDto);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    this.usersService.remove(id);
  }
}

The module wires everything together:

// users.module.ts
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

@Module({
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Once the NestJS Users module is fully tested, remove the corresponding route definitions from legacy-routes.js to avoid duplicate handlers. The migration of this route group is complete.

Step 4: Transform Express Middleware into NestJS Interceptors, Guards, and Pipes

Express middleware often serves multiple purposes: logging, authentication, validation, rate limiting. NestJS provides specialized constructs for each concern, making the code more intention-revealing.

Logging middleware → Interceptor: In Express, a logging middleware might look like this:

// express-logger.js
module.exports = function logger(req, res, next) {
  const start = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`${req.method} ${req.originalUrl} ${res.statusCode} - ${duration}ms`);
  });
  next();
};

In NestJS, this becomes an interceptor that wraps the execution context:

// logging.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable {
    const request = context.switchToHttp().getRequest();
    const start = Date.now();

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

Apply it globally or at the controller level:

// main.ts (global application)
import { LoggingInterceptor } from './logging.interceptor';

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

Authentication middleware → Guard: An Express JWT verification middleware:

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

module.exports = function auth(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ message: 'No token provided' });
  }
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(401).json({ message: 'Invalid token' });
  }
};

Becomes a NestJS guard that integrates with the framework's execution pipeline:

// auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import * as jwt from 'jsonwebtoken';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const token = request.headers.authorization?.split(' ')[1];

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

    try {
      const decoded = jwt.verify(token, process.env.JWT_SECRET);
      request.user = decoded;
      return true;
    } catch (err) {
      throw new UnauthorizedException('Invalid token');
    }
  }
}

Apply the guard using the @UseGuards() decorator:

@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
  // All routes in this controller require authentication
}

Validation middleware → Pipe: Express body validation using Joi or express-validator becomes a global validation pipe in NestJS. Enable the built-in ValidationPipe globally:

// main.ts
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );
  await app.listen(3000);
}

With this pipe active, all DTOs decorated with class-validator rules are automatically validated. Invalid requests receive a 400 Bad Request response with detailed error messages—no manual middleware needed.

Step 5: Migrate Error Handling to NestJS Exception Filters

Express error handling typically relies on custom error middleware with four parameters:

// express-error-handler.js
module.exports = function errorHandler(err, req, res, next) {
  console.error(err.stack);
  const status = err.status || 500;
  res.status(status).json({
    message: err.message,
    stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
  });
};

In NestJS, exception filters catch both thrown HTTP exceptions and unhandled errors. Create a global filter that mirrors your Express error handler:

// http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
  Logger,
} from '@nestjs/common';
import { Request, Response } from 'express';

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

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

    let status: number;
    let message: string;

    if (exception instanceof HttpException) {
      status = exception.getStatus();
      const responseBody = exception.getResponse();
      message =
        typeof responseBody === 'string'
          ? responseBody
          : (responseBody as any).message || 'Internal server error';
    } else {
      status = HttpStatus.INTERNAL_SERVER_ERROR;
      message = 'Internal server error';
    }

    this.logger.error(
      `${request.method} ${request.url} ${status} - ${message}`,
      exception instanceof Error ? exception.stack : '',
    );

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

Register it globally in main.ts:

app.useGlobalFilters(new AllExceptionsFilter());

Step 6: Convert Environment Configuration

Express applications commonly use dotenv and process.env scattered throughout the codebase. NestJS offers a structured configuration module that centralizes environment variables and provides type-safe access.

npm install @nestjs/config joi

Create a configuration file that defines the expected environment shape:

// configuration.ts
export default () => ({
  port: parseInt(process.env.PORT, 10) || 3000,
  database: {
    host: process.env.DATABASE_HOST || 'localhost',
    port: parseInt(process.env.DATABASE_PORT, 10) || 5432,
    username: process.env.DATABASE_USERNAME,
    password: process.env.DATABASE_PASSWORD,
    name: process.env.DATABASE_NAME,
  },
  jwt: {
    secret: process.env.JWT_SECRET,
    expiresIn: process.env.JWT_EXPIRES_IN || '1h',
  },
});

Load it in AppModule with schema validation:

// app.module.ts
import { ConfigModule } from '@nestjs/config';
import * as Joi from 'joi';
import configuration from './configuration';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      load: [configuration],
      validationSchema: Joi.object({
        PORT: Joi.number().default(3000),
        DATABASE_HOST: Joi.string().required(),
        DATABASE_PORT: Joi.number().default(5432),
        DATABASE_USERNAME: Joi.string().required(),
        DATABASE_PASSWORD: Joi.string().required(),
        DATABASE_NAME: Joi.string().required(),
        JWT_SECRET: Joi.string().required(),
        JWT_EXPIRES_IN: Joi.string().default('1h'),
      }),
    }),
    LegacyModule,
    UsersModule,
  ],
})
export class AppModule {}

Access configuration via the injected ConfigService:

// users.service.ts
import { ConfigService } from '@nestjs/config';

@Injectable()
export class UsersService {
  constructor(private configService: ConfigService) {
    const dbHost = this.configService.get('database.host');
    console.log(`Connected to database at ${dbHost}`);
  }
}

Step 7: Migrate Database Access to TypeORM or Prisma Modules

Express apps often use raw database drivers (pg, mysql2, mongodb) with manual connection pooling. NestJS integrates with ORMs through dedicated modules that handle connection lifecycle and repository injection.

Example migration from raw pg queries to TypeORM:

npm install @nestjs/typeorm typeorm pg

Define an entity that replaces raw SQL result parsing:

// user.entity.ts
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity('users')
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ length: 100 })
  name: string;

  @Column({ unique: true })
  email: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;
}

Register the TypeORM module in AppModule:

// app.module.ts (updated)
import { TypeOrmModule } from '@nestjs/typeorm';

@Module({
  imports: [
    ConfigModule.forRoot({ /* ... */ }),
    TypeOrmModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        type: 'postgres',
        host: configService.get('database.host'),
        port: configService.get('database.port'),
        username: configService.get('database.username'),
        password: configService.get('database.password'),
        database: configService.get('database.name'),
        entities: [User],
        synchronize: false,
        logging: true,
      }),
    }),
    TypeOrmModule.forFeature([User]),
    LegacyModule,
    UsersModule,
  ],
})
export class AppModule {}

The service now uses the injected repository instead of raw SQL:

// users.service.ts (refactored with TypeORM)
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository,
  ) {}

  async findAll(): Promise {
    return this.userRepository.find();
  }

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

  async create(createUserDto: CreateUserDto): Promise {
    const user = this.userRepository.create(createUserDto);
    return this.userRepository.save(user);
  }

  async update(id: number, updateUserDto: Partial): Promise {
    const user = await this.findOne(id);
    Object.assign(user, updateUserDto);
    return this.userRepository.save(user);
  }

  async remove(id: number): Promise {
    const result = await this.userRepository.delete(id);
    if (result.affected === 0) {
      throw new NotFoundException(`User with id ${id} not found`);
    }
  }
}

Step 8: Write Tests for Migrated Modules

One of the strongest motivations for migrating to NestJS is improved testability. NestJS's dependency injection allows services to be tested in isolation with mocked dependencies.

Unit test for UsersService:

// users.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { User } from './user.entity';
import { Repository } from 'typeorm';
import { NotFoundException } from '@nestjs/common';

describe('UsersService', () => {
  let service: UsersService;
  let repository: Repository;

  const mockRepository = {
    find: jest.fn(),
    findOne: jest.fn(),
    create: jest.fn(),
    save: jest.fn(),
    delete: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        UsersService,
        {
          provide: getRepositoryToken(User),
          useValue: mockRepository,
        },
      ],
    }).compile();

    service = module.get(UsersService);
    repository = module.get>(getRepositoryToken(User));
  });

  afterEach(() => {
    jest.clearAllMocks();
  });

  it('should return all users', async () => {
    const mockUsers = [{ id: 1, name: 'Test', email: 'test@test.com' }];
    mockRepository.find.mockResolvedValue(mockUsers);

    const result = await service.findAll();
    expect(result).toEqual(mockUsers);
    expect(mockRepository.find).toHaveBeenCalledTimes(1);
  });

  it('should throw NotFoundException when user does not exist', async () => {
    mockRepository.findOne.mockResolvedValue(null);

    await expect(service.findOne(999)).rejects.toThrow(NotFoundException);
  });

  it('should create a new user', async () => {
    const dto = { name: 'New User', email: 'new@example.com' };
    const savedUser = { id: 3, ...dto };
    mockRepository.create.mockReturnValue(dto);
    mockRepository.save.mockResolvedValue(savedUser);

    const result = await service.create(dto);
    expect(result).toEqual(savedUser);
  });
});

Controller tests are equally straightforward using the Test factory:

// users.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';

describe('UsersController', () => {
  let controller: UsersController;
  let service: UsersService;

  const mockService = {
    findAll: jest.fn(),
    findOne: jest.fn(),
    create: jest.fn(),
    update: jest.fn(),
    remove: jest.fn(),
  };

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      controllers: [UsersController],
      providers: [
        {
          provide: UsersService,
          useValue: mockService,
        },
      ],
    }).compile();

    controller = module.get(UsersController);
    service = module.get(UsersService);
  });

  it('should call findAll on the service', async () => {
    const users = [{ id: 1, name: 'Alice', email: 'alice@test.com' }];
    mockService.findAll.mockResolvedValue(users);

    expect(await controller.findAll()).toBe(users);
    expect(mockService.findAll).toHaveBeenCalled();
  });
});

Step 9: Remove Legacy Express Code After Full Migration

Once all route groups are migrated and verified, remove the LegacyModule from AppModule, delete the legacy-routes.js file, and uninstall any Express-specific middleware packages that are no longer needed. The application is now a pure NestJS codebase. Update the package.json scripts:

"scripts": {
  "build": "nest build",
  "start": "nest start",
  "start:dev": "nest start --watch",
  "start:prod": "node dist/main",
  "test": "jest",
  "test:e2e": "jest --config ./test/jest-e2e.json"
}

The final main.ts should look clean and declarative:

// main.ts (final)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { LoggingInterceptor } from './logging.interceptor';
import { AllExceptionsFilter } from './http-exception.filter';

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

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

  app.useGlobalInterceptors(new LoggingInterceptor());
  app.useGlobalFilters(new AllExceptionsFilter());

  await app.listen(process.env.PORT || 3000);
  console.log(`Application running on port ${process.env.PORT || 3000}`);
}
bootstrap();

Best Practices for a Smooth Migration

Conclusion

Migrating from Express to NestJS is a strategic evolution rather than a disruptive overhaul. By mounting Express routers as middleware within a NestJS application, you can transition route by route, module by module, without downtime or broken functionality. The result is a codebase with clear separation of concerns, automatic input validation, dependency injection, and vastly improved testability—all while still running on the battle-tested Express platform underneath. The discipline that NestJS imposes pays dividends as your application grows: new features are added in predictable locations, onboarding new developers becomes faster, and the test suite provides genuine confidence during refactoring. Start small with a single route group, prove the pattern works in your context, and steadily work through the migration checklist until the legacy Express code is a distant memory and your NestJS application stands complete, maintainable, and ready for the future.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles