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.

NestJS for Express Developers: What Actually Changes

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
  • req and res objects are still Express’s req and res
  • 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:

  1. @Controller('users') sets the base path — like app.use('/users', router) in Express
  2. @Get(), @Post(), @Put(), @Delete() map to HTTP methods — exactly like router.get(), router.post() etc.
  3. You return data directly instead of calling res.json() — NestJS handles serialization for you
  4. @Param('id') is NestJS’s equivalent of req.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 PatternNestJS EquivalentWhat 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 providers that 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