NestJS for Express Developers: What Actually Changes
Already know Express? Learn how NestJS builds on top of it with structure, TypeScript-first design, and built-in architecture — without throwing away what you already know.
Introduction To Nest JS
Part 1 of 6
Table of Contents
- The Biggest Difference: Express Gives You Freedom, NestJS Gives You Structure
- What Stays the Same: NestJS Runs on Express (or Fastify)
- Routes: From app.get() to @Controller() and @Get()
- Middleware → Still Works, But There’s More
- The Module System: Your App’s Table of Contents
- Services: Where Your Business Logic Actually Lives
- Project Structure: What a Real NestJS App Looks Like
- The Entry Point: main.ts vs index.js
- What You’ll Miss (At First) and What You Won’t
- Common Mistakes Express Developers Make in NestJS
- 1. Forgetting to add providers to the module
- Where to Go From Here
You’ve built APIs with Express. You know app.use(), you know req and res, you know how to wire up a route and send back some JSON. Express clicks for you — it’s simple, flexible, and gets out of your way.
Then someone mentions NestJS and suddenly you’re staring at @Controller(), @Injectable(), AppModule, and a folder structure that looks nothing like anything you’ve seen in Node.js before. It looks… over-engineered. Like someone tried to make Node.js feel like Java.
Here’s the thing — that instinct isn’t entirely wrong. NestJS is heavily inspired by Angular and Spring Boot. But once it clicks, you’ll realize it’s not over-engineering. It’s the structure that Express never gave you but eventually every serious Express project needs anyway.
This article is for Express developers who want to understand NestJS properly, not just copy-paste a tutorial. We’ll look at exactly what changes, what stays the same, and why NestJS makes certain decisions that initially feel strange.
💡 Prerequisites: You should be comfortable with Express and have some familiarity with TypeScript. You don’t need to be a TypeScript expert — NestJS actually teaches you a lot of it along the way.
The Biggest Difference: Express Gives You Freedom, NestJS Gives You Structure
With Express, you make all the architecture decisions yourself. Where do routes go? How do you split your logic? How do you handle dependency injection? You decide.
This is great for small projects. But the moment your codebase grows past a few files, you’re essentially re-inventing structure from scratch every time. Every Express project looks different because every developer makes different choices.
NestJS takes a different philosophy: it gives you the structure upfront, so you stop arguing about where things go and start just building. It’s opinionated — and that’s the point.
Think of it like this: Express is like buying raw lumber and building your own furniture. NestJS is like buying flat-pack furniture from IKEA. The decisions are already made. You just assemble it.
What Stays the Same: NestJS Runs on Express (or Fastify)
This is the part most tutorials skip over, but it’s important: NestJS is not a replacement for Express. It’s built on top of it.
Under the hood, when you create a NestJS app, it’s spinning up an Express server. Every HTTP request you handle in NestJS goes through Express first. That means:
- Everything you know about the Node.js HTTP model still applies
- Middleware written for Express can be used in NestJS
reqandresobjects are still Express’sreqandres- Performance characteristics are the same (unless you switch to Fastify)
You can even access the raw Express request object in NestJS if you ever need it:
import { Controller, Get, Req } from "@nestjs/common";
import { Request } from "express";
@Controller("users")
export class UsersController {
@Get()
findAll(@Req() req: Request) {
// req is the raw Express request object — same as always
console.log(req.headers);
}
}
So NestJS isn’t asking you to forget Express. It’s asking you to write Express apps with more structure.
Routes: From app.get() to @Controller() and @Get()
In Express, you define routes like this:
// Express
const router = express.Router();
router.get("/users", (req, res) => {
res.json({ users: [] });
});
router.get("/users/:id", (req, res) => {
const { id } = req.params;
res.json({ id });
});
app.use("/api", router);
In NestJS, the same routes look like this:
// NestJS
import { Controller, Get, Param } from "@nestjs/common";
@Controller("users") // maps to /users
export class UsersController {
@Get() // GET /users
findAll() {
return { users: [] }; // NestJS automatically serializes to JSON
}
@Get(":id") // GET /users/:id
findOne(@Param("id") id: string) {
return { id };
}
}
A few things to notice:
@Controller('users')sets the base path — likeapp.use('/users', router)in Express@Get(),@Post(),@Put(),@Delete()map to HTTP methods — exactly likerouter.get(),router.post()etc.- You return data directly instead of calling
res.json()— NestJS handles serialization for you @Param('id')is NestJS’s equivalent ofreq.params.id
Here’s a full CRUD comparison side by side:
// Express equivalents → NestJS
router.get('/') → @Get()
router.post('/') → @Post()
router.put('/:id') → @Put(':id')
router.patch('/:id') → @Patch(':id')
router.delete('/:id') → @Delete(':id')
req.params.id → @Param('id') id: string
req.query.page → @Query('page') page: string
req.body → @Body() body: CreateUserDto
req.headers['auth'] → @Headers('auth') auth: string
Middleware → Still Works, But There’s More
In Express, middleware is the answer to everything:
// Express middleware
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
NestJS supports Express-style middleware too, and you can apply it the same way for global concerns. But NestJS also introduces more specialized tools that do what middleware does, but more explicitly:
| Express Pattern | NestJS Equivalent | What it’s for |
|---|---|---|
app.use(authMiddleware) | Guards (@UseGuards()) | Authentication & authorization |
app.use(loggerMiddleware) | Interceptors (@UseInterceptors()) | Logging, response transformation, timing |
app.use(validateMiddleware) | Pipes (@UsePipes()) | Input validation and transformation |
app.use(errorHandler) | Exception Filters (@UseFilters()) | Global error handling |
app.use() | Middleware (still exists) | General-purpose, request/response manipulation |
Why split it up? Because when everything is middleware, it’s hard to know what a piece of middleware actually does. In NestJS, if you see @UseGuards(AuthGuard), you immediately know that’s authentication logic. If you see @UsePipes(ValidationPipe), you know that’s validating incoming data. It’s middleware — but with intent.
We’ll cover Guards, Interceptors, and Pipes in detail in future articles. For now, just know they exist and they replace the generic middleware pattern with purpose-built tools.
The Module System: Your App’s Table of Contents
This is the part that confuses most Express developers — and it’s genuinely the biggest mental shift.
In Express, you register things at the top level:
// Express — everything registered at app level
app.use("/users", usersRouter);
app.use("/auth", authRouter);
app.use(express.json());
In NestJS, everything is organized into Modules. A module is a class decorated with @Module() that tells NestJS what belongs together:
import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";
@Module({
controllers: [UsersController], // handles HTTP routes
providers: [UsersService], // business logic, services, etc.
exports: [UsersService], // what other modules can use
})
export class UsersModule {}
Think of a module like a feature folder that declares its own dependencies. Every NestJS app has a root AppModule that ties everything together:
import { Module } from "@nestjs/common";
import { UsersModule } from "./users/users.module";
import { AuthModule } from "./auth/auth.module";
@Module({
imports: [UsersModule, AuthModule], // bring in feature modules
})
export class AppModule {}
This scales much better than the Express approach. When your app has 20 features, you don’t have a massive index.js wiring everything manually. Each feature module is self-contained — it knows what it needs and what it offers.
💡 Analogy: If you’ve used React, think of modules like separate
context providersthat encapsulate related state and logic. Each module manages its own slice of the application.
Services: Where Your Business Logic Actually Lives
In Express, you might put logic directly in route handlers, or maybe extract it into utility functions. There’s no enforced pattern.
NestJS has a clear rule: Controllers handle HTTP. Services handle business logic. Nothing else.
Here’s what that looks like:
// users.service.ts — business logic lives here
import { Injectable } from "@nestjs/common";
@Injectable() // marks this class for NestJS's dependency injection system
export class UsersService {
private users = []; // in real life, this would be a database call
findAll() {
return this.users;
}
findOne(id: string) {
return this.users.find((user) => user.id === id);
}
create(data: CreateUserDto) {
const user = { id: Date.now().toString(), ...data };
this.users.push(user);
return user;
}
}
// users.controller.ts — HTTP handling only, delegates to service
import { Controller, Get, Post, Param, Body } from "@nestjs/common";
import { UsersService } from "./users.service";
@Controller("users")
export class UsersController {
constructor(private readonly usersService: UsersService) {}
// ↑ NestJS injects UsersService automatically
@Get()
findAll() {
return this.usersService.findAll(); // delegate, don't duplicate
}
@Get(":id")
findOne(@Param("id") id: string) {
return this.usersService.findOne(id);
}
@Post()
create(@Body() body: CreateUserDto) {
return this.usersService.create(body);
}
}
The constructor(private readonly usersService: UsersService) line is dependency injection — NestJS sees that the controller needs a UsersService and automatically creates and provides one. You never write new UsersService() yourself.
⚠️ Common mistake: Don’t put database queries or business logic directly in your controller. The controller’s only job is to receive the request, call the service, and return the response. The service does the actual work.
Project Structure: What a Real NestJS App Looks Like
When you run nest new my-app, you get this out of the box:
src/
├── app.controller.ts # root controller
├── app.module.ts # root module
├── app.service.ts # root service
└── main.ts # entry point (like your index.js in Express)
As your app grows, you add feature modules. A typical structure looks like:
src/
├── auth/
│ ├── auth.controller.ts
│ ├── auth.module.ts
│ ├── auth.service.ts
│ └── dto/
│ ├── login.dto.ts
│ └── register.dto.ts
├── users/
│ ├── users.controller.ts
│ ├── users.module.ts
│ ├── users.service.ts
│ └── entities/
│ └── user.entity.ts
├── app.module.ts
└── main.ts
Compare this to a typical Express project:
src/
├── routes/
│ ├── auth.routes.js
│ └── users.routes.js
├── controllers/
│ └── ...
├── services/
│ └── ...
├── middleware/
│ └── ...
└── index.js
The NestJS structure isn’t necessarily more files — it’s more deliberate about where each type of file goes. You never have to debate it.
The Entry Point: main.ts vs index.js
In Express, your entry point usually looks like this:
// Express index.js
const express = require("express");
const app = express();
app.use(express.json());
app.use("/api/users", usersRouter);
// ... more setup
app.listen(3000, () => console.log("Server running on port 3000"));
In NestJS:
// NestJS main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api"); // like app.use('/api', ...)
app.useGlobalPipes(new ValidationPipe()); // global request validation
await app.listen(3000);
}
bootstrap();
It’s leaner because the heavy lifting — wiring controllers, services, and middleware — is handled inside the modules. main.ts just bootstraps the app and adds global config.
What You’ll Miss (At First) and What You Won’t
What feels like a loss moving to NestJS:
- The simplicity of
app.get('/path', handler)— NestJS decorators feel verbose at first - The freedom to structure things however you want
- Less googling for “how to add middleware” since everything has an opinionated home
What you won’t miss once you’re used to NestJS:
- Debating folder structure every new project
- Writing your own DI container or service locator pattern
- Debugging why middleware order matters in a 500-line
index.js - TypeScript types that don’t carry through across your app
Common Mistakes Express Developers Make in NestJS
1. Forgetting to add providers to the module
If you create a service but don’t add it to the providers array in the module, NestJS will throw a dependency injection error at startup. Always declare your services:
@Module({
controllers: [UsersController],
providers: [UsersService], // ← don't forget this
})
export class UsersModule {}
2. Calling res.json() manually
You don’t need to do this in NestJS. Just return data from your controller method and NestJS serializes it automatically. If you need to control the status code, use @HttpCode(201) or return a HttpException.
3. Putting logic in the controller
The controller is just a traffic cop — it receives requests and delegates. All real logic goes in the service.
Where to Go From Here
NestJS has a lot more to explore — Guards for authentication, Interceptors for logging, Pipes for validation, and TypeORM or Prisma for database work. But you now understand the foundation: how routing maps from Express to NestJS, why modules exist, and how the controller/service split works.
In the next article, we’ll cover JWT authentication in NestJS from scratch — building a real login and registration system with Guards and Passport. [TODO: link when published]
If you’re already familiar with how Express authentication works with Passport, that article will feel very familiar. If you’re not, we’ll cover everything you need.
Have questions or something didn’t click? Drop a comment below — I read everything.
Already know Express? Learn exactly what changes in NestJS — routing, middleware, project structure, and the module system — explained for JavaScript developers.
Continue the series →
Next up: NestJS Modules, Controllers and Services — the architecture finally explained.
Read next article →Related Posts
NestJS + PostgreSQL with TypeORM: Complete Setup Guide
Learn how to connect NestJS to PostgreSQL using TypeORM — from installation to entities, relations, and repositories. With real code and the mistakes you'll actually make.
NestJS Modules, Controllers, and Services: The Architecture Finally Explained
Confused by NestJS modules, controllers, and services? This guide breaks down the core architecture in plain terms, with real code examples and analogies that actually stick.
NestJS Guards vs Middleware: When to Use Which
Guards and middleware look similar in NestJS but they solve very different problems. Here's how to tell them apart and know exactly when to reach for each one.