Skip to content
ExponaBLOG
NESTJS · DAY 096 / 100

Migrating Express to NestJS: what developers should know

An incremental path, not a rewrite.

Expona Admin9 min read

Migrating Express to NestJS: An Incremental Path, Not a Rewrite

Most teams approach a migration from Express to NestJS as a fork-the-repo-and-rewrite adventure, and that's how projects derail. I've watched production systems go dark for weeks while developers tried to translate every route, middleware, and utility in one go. The smarter path is incremental: run both frameworks side-by-side, migrate feature by feature, and keep serving traffic the entire time. In this post, I'll walk you through a battle-tested strategy for Migrating Express to NestJS without the all-or-nothing risk.

Why Incremental Migration Matters

An incremental approach buys you several critical advantages:

  • Zero downtime. Your Express app keeps handling requests while Nest routes come online piece by piece.
  • Easier rollback. If a migrated feature breaks, you revert a single module, not your entire stack.
  • Team velocity. Different developers can work on different features in parallel without merge conflicts on the core server file.
  • Production validation. You catch integration bugs with real traffic, not just in staging tests.

The catch? You need discipline. You can't migrate the auth layer, leave it half-done, and move on to payments. But with a clear structure, it's manageable even for large codebases.

Mount Express Inside Nest — Leverage the Framework's Flexibility

Here's the key insight: NestJS runs on top of Express (or Fastify) by default. You're not replacing Express; you're wrapping it. This means your existing Express routers, middleware, and handlers can coexist with new Nest controllers and services.

Start by creating a bare NestJS app and mounting your entire Express app as a fallback middleware:

typescript
// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import express, { Express } from 'express';
import { legacyApp } from './legacy/express-app';

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

  // Mount the old Express app under a prefix (or at root)
  app.use('/api/legacy', legacyApp);

  // Or mount it as a catch-all for unmigrated routes
  app.use((req, res, next) => {
    // If no Nest route matches, fall through to Express
    legacyApp(req, res, next);
  });

  await app.listen(3000);
}

bootstrap();

In production, I've seen teams use a reverse proxy (nginx, load balancer) to route traffic based on path. For example, /api/users/* hits the new Nest service while /api/legacy/* goes to the old Express instance. This is even cleaner if you run both services in separate containers, but co-location works for smaller teams.

The big win: your team can now deploy Nest changes without touching the Express handlers. You've decoupled the migration from your deployment process.

Migrate by Feature — Move One Route Group at a Time

Don't migrate individual routes. Migrate by feature: users, products, orders, invoices. A feature is a cohesive set of endpoints that share data models and business logic.

Here's a realistic scenario. Your Express app has:

  • GET /users, POST /users, GET /users/:id, PATCH /users/:id
  • GET /users/:id/profile, PATCH /users/:id/profile

These all belong in a single Nest module. Create it in isolation:

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

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

Your controller mirrors the Express routes:

typescript
// src/users/users.controller.ts
import {
  Controller,
  Get,
  Post,
  Patch,
  Param,
  Body,
  Query,
  HttpCode,
  HttpStatus,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto, UpdateUserDto } from './dto';

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

  @Get()
  async list(@Query('page') page: number = 1) {
    return this.usersService.findAll({ page });
  }

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

  @Get(':id')
  async getById(@Param('id') id: string) {
    return this.usersService.findById(id);
  }

  @Patch(':id')
  async update(@Param('id') id: string, @Body() dto: UpdateUserDto) {
    return this.usersService.update(id, dto);
  }

  @Get(':id/profile')
  async getProfile(@Param('id') id: string) {
    return this.usersService.getProfile(id);
  }

  @Patch(':id/profile')
  async updateProfile(@Param('id') id: string, @Body() dto: UpdateProfileDto) {
    return this.usersService.updateProfile(id, dto);
  }
}

Now, remove those same routes from your Express app. Don't delete the handler files—archive them in a /legacy folder until you're confident the Nest version is stable. Import the UsersModule into your AppModule and you're done.

Repeat this process for each feature. The order matters: start with features that have few dependencies, then move upward.

Shared Infrastructure First — Config, Logging, DB, and Auth

Before you migrate your first feature module, set up the foundational providers. These are pieces every feature depends on: database connections, configuration, logging, and authentication.

In Express, these often lived as singletons in utility files. In Nest, they're providers in a shared module:

typescript
// src/shared/shared.module.ts
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerService } from './logger/logger.service';
import { DatabaseService } from './database/database.service';
import { AuthService } from './auth/auth.service';

@Global()
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: process.env.NODE_ENV === 'test' ? '.env.test' : '.env',
    }),
  ],
  providers: [ConfigService, LoggerService, DatabaseService, AuthService],
  exports: [ConfigService, LoggerService, DatabaseService, AuthService],
})
export class SharedModule {}

A concrete example: your Express app opens a Postgres pool at startup. Wrap that in a Nest provider:

typescript
// src/shared/database/database.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Pool, Client } from 'pg';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
  private pool: Pool;

  constructor(private readonly configService: ConfigService) {}

  async onModuleInit() {
    this.pool = new Pool({
      connectionString: this.configService.get('DATABASE_URL'),
    });
    await this.pool.query('SELECT 1'); // Test connection
  }

  async onModuleDestroy() {
    await this.pool.end();
  }

  query(sql: string, params?: unknown[]) {
    return this.pool.query(sql, params);
  }

  getPool() {
    return this.pool;
  }
}

Now every service in every migrated feature can inject DatabaseService and use the same connection. This also means you test the database layer once, not for every feature.

For authentication, create a guard that mirrors your Express middleware:

typescript
// src/shared/auth/auth.guard.ts
import {
  Injectable,
  CanActivate,
  ExecutionContext,
  UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from './auth.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private readonly authService: AuthService) {}

  async canActivate(context: ExecutionContext): Promise {
    const request = context.switchToHttp().getRequest();
    const token = this.extractToken(request);

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

    try {
      const user = await this.authService.validateToken(token);
      request.user = user;
      return true;
    } catch (err) {
      throw new UnauthorizedException('Invalid token');
    }
  }

  private extractToken(request: any): string | null {
    const auth = request.headers['authorization'];
    return auth?.replace('Bearer ', '');
  }
}

Apply this guard to any controller that needs auth:

typescript
@Controller('users')
@UseGuards(AuthGuard)
export class UsersController {
  // ...
}

Investing time here pays off immediately. Once these are solid, each new feature module is a 20-minute job, not two hours of debugging.

Test Parity — Contract Tests Ensure Responses Match

Your Express endpoints return specific JSON shapes. If your Nest endpoints return different shapes, client code breaks. Test parity ensures they're identical.

Use contract tests (or consumer-driven tests) to lock down the API contract before you migrate:

typescript
// test/users.contract.spec.ts
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AppModule } from '../src/app.module';
import axios from 'axios';

describe('Users API Contract', () => {
  let app: INestApplication;
  let expressApiUrl: string;
  let nestApiUrl: string;

  beforeAll(async () => {
    const moduleFixture: TestingModule = await Test.createTestingModule({
      imports: [AppModule],
    }).compile();

    app = moduleFixture.createNestApplication();
    await app.listen(3001);
    nestApiUrl = 'http://localhost:3001';
    expressApiUrl = 'http://localhost:3000';
  });

  afterAll(async () => {
    await app.close();
  });

  it('should return identical user list response', async () => {
    const [nestRes, expressRes] = await Promise.all([
      axios.get(`${nestApiUrl}/users?page=1`),
      axios.get(`${expressApiUrl}/users?page=1`),
    ]);

    expect(nestRes.data).toEqual(expressRes.data);
    expect(nestRes.status).toBe(expressRes.status);
  });

  it('should return identical user by ID response', async () => {
    const userId = 'user-123';
    const [nestRes, expressRes] = await Promise.all([
      axios.get(`${nestApiUrl}/users/${userId}`),
      axios.get(`${expressApiUrl}/users/${userId}`),
    ]);

    expect(nestRes.data).toEqual(expressRes.data);
  });

  it('should reject with same error shape on 401', async () => {
    try {
      await axios.get(`${nestApiUrl}/users/protected`);
    } catch (nestErr: any) {
      try {
        await axios.get(`${expressApiUrl}/users/protected`);
      } catch (expressErr: any) {
        expect(nestErr.response.status).toBe(expressErr.response.status);
        expect(nestErr.response.data).toEqual(expressErr.response.data);
      }
    }
  });
});

Run these tests in your CI/CD pipeline. They're your safety net. If a contract test fails during migration, you know exactly which response shape changed before it hits production.

Common Mistakes

  • Migrating too many features at once. This creates merge conflicts and makes debugging harder. Stick to one or two features per sprint.
  • Forgetting error handling parity. Your Express app returns specific error codes and messages. Your Nest app must do the same. Test this explicitly.
  • Skipping the shared infrastructure phase. You'll end up duplicating config, logging, and DB logic across modules. Build it once in SharedModule and reuse it.
  • Not removing Express routes as you migrate. This creates confusion: which version is running? Use feature flags or explicit removal to keep the codebase clean.
  • Testing only in isolation. Unit tests pass, but integration breaks when your Nest service calls a database or third-party API. Run end-to-end tests against a staging environment.

Key Takeaways

  • Mount your Express app as middleware inside Nest to run both side-by-side with zero downtime.
  • Migrate by feature, not by route, so each module is self-contained and testable.
  • Build shared infrastructure (config, logging, auth, database) once in a Global module before you start feature migrations.
  • Use contract tests to lock down API responses and catch breaking changes before production.
  • Archive, don't delete, old Express handlers until you're confident the Nest version is stable in production.
  • Apply this incrementally: one feature per sprint, one feature at a time. A three-month migration is better than a six-week outage.
---
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles