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.
- Express: you decide everything. Fast to start, but every project ends up structured differently — including projects inside the same company.
- NestJS: the framework decides. Slower to start, but any developer who knows Nest can open your project and find things immediately.
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.
- S — Single responsibility. One class, one job. The controller only deals with HTTP. The service only deals with business rules. The repository only talks to the database. Nest's structure makes mixing them feel wrong.
- O — Open for extension, closed for modification. Need auth on a route? Add a guard. Need logging? Add an interceptor. You add behaviour without editing the controller you already tested.
- L — Liskov substitution. If two classes implement the same interface,
either one should work. Swap
EmailServiceforMockEmailServiceand nothing else changes. - I — Interface segregation. Small, focused providers instead of one giant service class that does everything.
- D — Dependency inversion. This is the big one, and it is what dependency injection actually means.
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
- Validation lives in one place. Not scattered across routes, not forgotten in the one endpoint nobody tested.
- That
whitelist: trueline is security. Any field not in the DTO gets removed. Someone sendingisAdmin: trueorbalance: 999999gets it silently stripped. This is mass assignment, and it is a genuinely common vulnerability. - The DTO is documentation. A new developer reads
CreateUserDtoand knows exactly what the endpoint accepts. Add@nestjs/swaggerand it generates the API docs from the same class. - Your types are real. TypeScript alone disappears at runtime — a
typeannotation checks nothing once deployed. A DTO with decorators checks at runtime too.
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
- You are building something small — a webhook receiver, an internal tool, a proxy.
- You are the only developer, or there are two of you.
- You need to ship this week.
- You are still learning how HTTP and middleware actually work. Express teaches you that; Nest hides it.
Choose NestJS when
- More than three developers will touch the code.
- The project will live for years and new people will join.
- You are already using TypeScript and want it enforced properly.
- You need many cross-cutting things — auth guards, validation, logging, queues — and you do not want to hand-roll each one.
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:
- Small service, short life, one or two developers → Express.
- Product backend that a team will maintain → NestJS.
- 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.