Express is the most-used Node.js framework ever written. It's minimal, unopinionated, and has shipped more backends than any other JavaScript tool. NestJS is the heavyweight counterweight: opinionated, TypeScript-first, modeled on Angular, and built for teams that want structure handed to them.
Picking between them comes down to how much framework you want. This guide compares NestJS and Express on the dimensions that actually matter when you're choosing, architecture, TypeScript support, productivity, performance, and ecosystem, with honest tradeoffs and concrete code. We also introduce a third option that's worth knowing about because it solves problems both frameworks leave to you.
| Aspect | Express | NestJS |
|---|---|---|
| First release | 2010 | 2017 |
| Philosophy | Minimal, unopinionated | Opinionated, Angular-style |
| TypeScript | Optional (typings needed) | First-class |
| Architecture | Middleware chain | Modules, controllers, providers, DI |
| Validation | DIY (bring your own) | Built-in (class-validator) |
| Testing | DIY setup | Built-in testing module |
| Learning curve | Low | Medium-high |
| Performance | ~30k req/sec | ~28k req/sec (Express adapter) |
| Community size | Very large | Large, growing |
| Typical use | APIs, middleware, prototypes | Enterprise backends, large teams |
Express is a middleware dispatcher. You register middleware functions, and each request flows through them in order. A route handler is just a function with (req, res, next).
import express from "express";
const app = express();
app.use(express.json());
app.get("/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id);
if (!user) return res.status(404).json({ error: "not found" });
res.json(user);
});
app.listen(3000);
That's the whole app. There's no module system, no DI container, no project structure conventions. You organize files however you want. For small projects this is freeing; for large ones it becomes an archaeology problem.
NestJS gives you a strict architecture: modules group related code, controllers handle HTTP, providers hold business logic, and a DI container wires them together.
// users.controller.ts
import { Controller, Get, Param, NotFoundException } from "@nestjs/common";
import { UsersService } from "./users.service";
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get(":id")
async findOne(@Param("id") id: string) {
const user = await this.usersService.findOne(id);
if (!user) throw new NotFoundException();
return user;
}
}
// users.service.ts
import { Injectable } from "@nestjs/common";
@Injectable()
export class UsersService {
async findOne(id: string) {
return await this.db.users.findById(id);
}
}
// users.module.ts
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}
Same endpoint, three files and a module registration. In exchange you get dependency injection, automatic testing setup, consistent project structure across teams, and a convention for every common backend need.
Express was written before TypeScript was popular. It has third-party typings (@types/express), but req.body, req.params, and req.query are typed as any by default. You have to add validation middleware (Zod, Joi, class-validator) and manually narrow types in every handler.
NestJS was built TypeScript-first. Validation is built in via class-validator, parameters are typed at the decorator level, and the DI container is fully typed. You get compile-time safety across the whole application.
This matters more than it sounds. In a 50-endpoint Express app, the "what shape is this request body" question gets answered differently by different developers. In NestJS, there's one way to do it.
Express is faster for quick starts. You can have a working API in 10 lines. You'll move fast until you need authentication, validation, database access, testing setup, and error handling, at which point you're assembling a framework from npm packages, and every team does it slightly differently.
NestJS is slower to start (more to learn) but more consistent at scale. Everyone writes code the same way. Adding new developers is easier because the conventions are the framework, not tribal knowledge.
A rough heuristic:
Both frameworks are fast enough for almost all workloads. On identical hardware running a simple JSON endpoint:
NestJS uses Express as its default HTTP layer, so it inherits Express's performance minus a small DI overhead. Swapping to the Fastify adapter makes NestJS faster than raw Express. In practice, your database and network I/O dominate, framework overhead is rarely the bottleneck.
Express has the largest middleware ecosystem in JavaScript. Any problem you hit has 5 npm packages already solving it. The downside is quality and maintenance vary wildly.
NestJS has a smaller but more curated ecosystem. Its @nestjs/* packages cover common needs (auth, config, throttling, caching, GraphQL, WebSockets, microservices) and are maintained by the core team. Beyond those, you can usually wrap any Express middleware inside a NestJS app.
Express has no built-in validation. You install Zod, Joi, class-validator, or similar.
// Express with Zod
import { z } from "zod";
const CreateUser = z.object({
email: z.string().email(),
name: z.string().min(1),
});
app.post("/users", (req, res) => {
const parsed = CreateUser.safeParse(req.body);
if (!parsed.success) return res.status(400).json(parsed.error);
// ... use parsed.data
});
NestJS has it wired in via class-validator and class-transformer:
// NestJS DTO
import { IsEmail, IsString, MinLength } from "class-validator";
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
name: string;
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.usersService.create(dto);
}
The validation happens before your handler runs. Fewer places to forget it.
Express gives you supertest and a blank page. You write your own test setup, mock your own dependencies, and decide your own conventions.
NestJS ships a @nestjs/testing module that integrates with the DI container. You can override any provider with a mock in a single line:
const module = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{
provide: UsersService,
useValue: { findOne: jest.fn().mockResolvedValue({ id: "1" }) },
},
],
}).compile();
For unit tests, NestJS saves real time. For end-to-end tests, both frameworks converge on supertest-style HTTP testing.
Use Express when:
Use NestJS when:
Both Express and NestJS stop at the application boundary. They help you write HTTP handlers. Everything outside that (databases, Pub/Sub, cron jobs, deployment, tracing, service-to-service type safety), is left to you. That's fine for small apps and painful for larger systems.
Encore is a newer TypeScript backend framework that takes a different approach: you declare infrastructure as typed objects in your code, and Encore provisions the actual resources (Postgres, Pub/Sub, Cron) on AWS or GCP. The same TypeScript that defines your APIs also defines your infra.
The endpoint that took a controller + service + module in NestJS, or a route handler + custom validation in Express, is one function in Encore:
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
// Provisions managed Postgres, locally via Docker, in production on RDS or Cloud SQL.
const db = new SQLDatabase("users", { migrations: "./migrations" });
interface User {
id: number;
email: string;
name: string;
}
export const get = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: number }): Promise<User> => {
return await db.queryRow`SELECT * FROM users WHERE id = ${id}`;
},
);
There are no decorators, no DTOs, no DI container, and no validation middleware. Types are the contract: requests are validated against them automatically, and Encore generates an OpenAPI spec, typed client SDKs, and distributed traces from the same definitions.
What Encore handles that Express and NestJS don't:
ClientProxy.send returns any)encore run starts the whole system (services, databases, Pub/Sub) locally with one commandEncore has over 11,000 GitHub stars and is used in production by companies including Groupon. It's open source.
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.
For a new TypeScript backend in 2026 where you don't have existing code to preserve, the honest ranking by time-to-productive-system is:
For an existing Express or NestJS codebase, migration only makes sense if you're hitting real pain (type-safety gaps across services, infrastructure drift, observability blind spots). Otherwise staying put is usually the right call.
# Express
npm init -y && npm install express @types/express typescript
# NestJS
npm install -g @nestjs/cli && nest new project-name
# Encore
brew install encoredev/tap/encore
encore app create my-app --example=ts/empty
cd my-app && encore run
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.