← All posts
Aug 28, 2025 · 13 min read

NestJS vs Express: which one should you pick?

I have shipped production apps on both. Express gives you a blank page. NestJS gives you a filled-in form. If you know Spring Boot, NestJS will feel familiar. Here is how to decide, step by step, in plain language.

Node.jsNestJSExpressSpring BootSOLIDDTOs

If you write backend code in Node.js, you will run into this question early. Both Express and NestJS are used for the same job — building an API — but they feel very different once your project grows.

I have used Express at two companies and NestJS at two others. Here is what I actually think, without the marketing.

Step 1: Understand what each one is

Express is a library

Express does one thing: it takes an HTTP request and lets you respond to it. That is basically all. Everything else — folder structure, validation, database connection, error handling — is your decision.

const express = require("express");
const app = express();

app.get("/users/:id", async (req, res) => {
  const user = await db.users.findById(req.params.id);
  res.json(user);
});

app.listen(3000);

That is a working API in six lines. Nothing is hidden from you.

NestJS is a framework

NestJS also handles HTTP requests, but it comes with opinions. It tells you to split your code into modules, controllers and services, and it wires them together for you.

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

  @Get(":id")
  findOne(@Param("id") id: string) {
    return this.usersService.findOne(id);
  }
}

More code for the same result. But notice: the controller does not know how the database works. It just asks UsersService. That separation is the whole point.

Step 2: Know the real difference

The honest difference is not features. It is who makes the decisions.

Everything else follows from this. NestJS has built-in dependency injection, validation pipes, guards for auth, interceptors, and a testing setup. You can add all of that to Express — you just have to choose and wire the libraries yourself.

Step 3: If you know Spring Boot, you already know NestJS

This is the fastest way to understand NestJS. It is very close to Spring Boot in Java — same ideas, same structure, different language. The NestJS team took that style (through Angular) and brought it to Node.

If you have written Spring Boot, this table is basically the whole framework:

Spring Boot (Java)        NestJS (TypeScript)
------------------        -------------------
@RestController      →    @Controller
@Service             →    @Injectable
@Autowired           →    constructor injection
@Component           →    @Injectable
@Configuration       →    @Module
Filters              →    Guards
Interceptors         →    Interceptors
Bean validation      →    Pipes (class-validator)

Even the folder layout matches: controller handles the request, service holds the business logic, repository talks to the database. If you moved from Java to Node and missed that structure, NestJS is the closest thing you will find.

Where SOLID comes in

SOLID is five principles for keeping code easy to change. You do not have to memorise the names — but NestJS pushes you toward them by default, and that is a real part of why teams pick it.

Dependency inversion, in plain words

Normally your code creates what it needs:

class UsersService {
  constructor() {
    this.db = new PostgresClient();   // hard-wired
    this.email = new SendGridClient(); // hard-wired
  }
}

This class is now stuck. It always uses Postgres and SendGrid. To test it you need a real database and you will send real emails.

With dependency injection, the class just asks for what it needs and something else supplies it:

@Injectable()
export class UsersService {
  constructor(
    private db: Database,
    private email: EmailSender,
  ) {}
}

Nest reads that constructor and passes in the right objects automatically. Now in a test you hand it fakes:

const service = new UsersService(fakeDb, fakeEmail);
// no database, no emails sent, runs in milliseconds

That is dependency inversion: the class depends on what it needs (EmailSender), not on a specific thing (SendGridClient). Switching from SendGrid to Amazon SES later means writing one new class and changing one line in a module — not hunting through every file.

One honest caveat

SOLID is a way of writing code, not a feature you get for free by installing a framework. You can write perfectly SOLID code in Express — pass dependencies into your functions instead of importing them directly, and you have done the same thing. And you can absolutely write terrible code in NestJS: 900-line services, business logic stuffed into controllers, everything injected everywhere.

The difference is the default. In NestJS the clean path is the easy path, so a team drifts toward it. In Express the clean path is a decision someone has to make on purpose, and keep making, every week.

Step 4: DTOs — how Nest checks what comes in

DTO stands for Data Transfer Object. The name sounds heavier than the idea. A DTO is just a small class that describes the shape of the data coming into your API — nothing more.

The problem it solves

In Express, whatever the client sends lands in req.body and you trust it:

app.post("/users", async (req, res) => {
  const { email, age } = req.body;
  // Is email actually an email? Is age a number or the string "abc"?
  // Did they also send isAdmin: true and you just saved it?
  await db.createUser(req.body);
});

So you write validation by hand, in every route, and forget it in a few of them. That last comment is a real bug class — passing the whole body straight into your database lets someone add fields you never intended.

The DTO version

You describe the shape once, as a class:

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(2)
  name: string;

  @IsInt()
  @Min(18)
  age: number;
}

Then you use it in the controller:

@Post()
create(@Body() dto: CreateUserDto) {
  // If the code reaches here, the data is already valid.
  return this.usersService.create(dto);
}

Turn it on once, in main.ts:

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,           // strip fields not in the DTO
  forbidNonWhitelisted: true // or reject the request outright
}));

Now a bad request never reaches your service. Nest returns a 400 with a clear message listing what was wrong, and you did not write a single if statement.

Why this matters more than it looks

One rule worth following

Keep DTOs and database models separate. It is tempting to reuse your entity as the DTO, but they answer different questions: the DTO is what the client may send, the entity is what you store. They drift apart fast — you do not want the client able to set createdAt, role or passwordHash just because those exist in the table.

You can do all of this in Express with Zod or Joi, and it works well. The difference, again, is the default: in Nest the validated path is the normal path, so it is what people actually do.

Step 5: Pick based on your situation

Choose Express when

Choose NestJS when

Step 6: A thing people get wrong

NestJS runs on top of Express by default. It is not a competitor at the HTTP layer — it is a structure layer sitting above it. So "Nest is slower than Express" is mostly wrong. The overhead is small and it is almost never your bottleneck. Your database query is your bottleneck.

Better still, that layer underneath is swappable. Nest calls it a platform adapter, and it ships with two: Express (the default) and Fastify, which is a faster HTTP server. Switching is roughly two lines:

// npm i @nestjs/platform-fastify

const app = await NestFactory.create<NestFastifyApplication>(
  AppModule,
  new FastifyAdapter(),
);

Your controllers, services, DTOs and modules do not change at all. That is the payoff of the structure — the framework code sits above the HTTP library, so the HTTP library can be replaced underneath it.

Two honest caveats. First, Fastify is meaningfully faster at raw request handling, but you will only notice if request handling is genuinely your bottleneck — measure before you switch. Second, any Express-specific middleware you installed will not work, because it is no longer Express. Fastify has its own equivalents, but it is a real migration, not a free win.

If your API is slow, it is your queries or your missing cache, not your framework. I have never once fixed a performance problem by switching frameworks.

Step 7: What I do now

My rough rule:

  1. Small service, short life, one or two developers → Express.
  2. Product backend that a team will maintain → NestJS.
  3. Already have a messy Express codebase and the team keeps growing → move to NestJS gradually, one module at a time. You do not need a rewrite.

And a small warning from experience: NestJS has a real learning curve. Decorators, dependency injection and modules take a week or two to click. If your deadline is Friday, that is not the week to learn it.

Short version: Express is a library that gets out of your way. NestJS is a framework that keeps a team consistent. Pick based on team size and how long the code has to live — not on benchmarks.

← All posts Questions? Get in touch →