NestJS

Node.js vs NestJS: When Should You Use Each?

When a minimal Node.js/Express setup is enough, and when NestJS's structure actually pays off.

Tayyab Roy7 min read
Share:

This question comes up on nearly every new backend project: plain Node with Express or Fastify, or NestJS. They’re not really competitors — NestJS is built on top of Node (and can run on Express or Fastify under the hood) — but they represent genuinely different defaults, and picking the wrong one for a project’s actual trajectory costs you later, either in needless boilerplate or in an unstructured codebase that’s painful to onboard people into.

The Same Endpoint, Two Styles

Nothing clarifies the difference like seeing the same simple GET /products route built both ways.

Plain Node.js with Express:

// routes/products.js
const express = require('express');
const router = express.Router();
const productService = require('../services/productService');

router.get('/products', async (req, res) => {
  try {
    const { category } = req.query;
    const products = await productService.findAll({ category });
    res.json(products);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Failed to fetch products' });
  }
});

module.exports = router;
// services/productService.js
const db = require('../db');

async function findAll({ category }) {
  const query = category
    ? 'SELECT * FROM products WHERE category = $1'
    : 'SELECT * FROM products';
  const { rows } = await db.query(query, category ? [category] : []);
  return rows;
}

module.exports = { findAll };
// app.js
const express = require('express');
const app = express();
app.use(express.json());
app.use('/api', require('./routes/products'));
app.listen(3000);

Nothing is hidden. The request comes in, hits a handler, calls a function, returns JSON. There’s no framework convention you need to learn beyond Express’s own routing — the entire request lifecycle is visible in about twenty lines.

NestJS equivalent:

// products/products.controller.ts
import { Controller, Get, Query } from '@nestjs/common';
import { ProductsService } from './products.service';

@Controller('products')
export class ProductsController {
  constructor(private readonly productsService: ProductsService) {}

  @Get()
  findAll(@Query('category') category?: string) {
    return this.productsService.findAll(category);
  }
}
// products/products.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Product } from './product.entity';

@Injectable()
export class ProductsService {
  constructor(@InjectRepository(Product) private repo: Repository<Product>) {}

  findAll(category?: string) {
    return category
      ? this.repo.find({ where: { category } })
      : this.repo.find();
  }
}
// products/products.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';
import { Product } from './product.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Product])],
  controllers: [ProductsController],
  providers: [ProductsService],
})
export class ProductsModule {}

More files, more ceremony, and a dependency injection container doing work that’s invisible unless you already know Nest’s conventions. For this single endpoint, Express wins on simplicity, full stop. The difference shows up once the app grows.

What NestJS Actually Buys You

NestJS’s value isn’t in any single endpoint — it’s in what happens at the 30th endpoint, the 5th module, and the 4th engineer joining the team.

  • Enforced modularity. Modules, controllers, and providers aren’t optional conventions you hope the team follows — they’re structural. A new feature has an obvious place to live, and Nest won’t let a controller directly reach into another module’s internals without going through its exports.
  • Built-in dependency injection. Services, repositories, and config get injected rather than imported and instantiated ad hoc. This makes unit testing dramatically easier — swap a real ProductsService for a mock in a test module without touching the controller.
  • Decorators for cross-cutting concerns. Guards (@UseGuards), interceptors, pipes for validation (@UsePipes(ValidationPipe)), and exception filters give you consistent, declarative handling of auth, logging, and input validation instead of hand-rolled middleware chains that drift in behavior across routes.
  • First-class TypeScript. Nest is TypeScript-first in a way that bolting types onto an Express app with @types/express never quite matches — request DTOs, response types, and the DI container’s types all line up.
@Post()
@UsePipes(new ValidationPipe({ whitelist: true }))
create(@Body() dto: CreateProductDto) {
  return this.productsService.create(dto);
}

That ValidationPipe plus a class-validator-annotated DTO replaces a wall of manual if (!body.name) throw ... checks, consistently, across every endpoint that uses it — not just the ones a particular engineer remembered to guard.

What Plain Node Buys You

  • Speed to a working endpoint. No modules to wire up, no DI container to understand, no decision about which of Nest’s conventions to use for a given cross-cutting concern.
  • Full visibility. Every request’s path through the code is explicit. For small teams or services with a narrow, well-understood scope, this is a feature, not a limitation — there’s nothing “magic” to debug.
  • Lower bundle/cold-start overhead. Relevant specifically for serverless functions or edge runtimes, where Nest’s DI container and module resolution add measurable cold-start time compared to a minimal Express or Fastify handler.
  • No framework lock-in to learn. Any Node developer can read an Express route with zero prior exposure to the project’s conventions.

Concrete Guidance on When to Use Each

Reach for plain Node.js (Express/Fastify) when:

  • The service is small in scope — a handful of endpoints, a webhook receiver, a single-purpose microservice, a serverless function.
  • The team is one or two engineers who already have shared context and don’t need enforced structure to stay consistent.
  • You expect the service’s scope to stay narrow — it’s not going to grow into a platform with dozens of resources and cross-team ownership.
  • Cold-start latency matters (serverless/edge) and the service doesn’t need Nest’s DI machinery to justify its overhead.

Reach for NestJS when:

  • You’re building an API that multiple engineers — especially engineers who weren’t there for the early decisions — will maintain over a long period.
  • The domain has real complexity: multiple resources, shared cross-cutting concerns (auth, logging, validation) that need to behave identically everywhere, background jobs, multiple data sources.
  • You want enforced consistency more than you want flexibility — a new team member should be able to guess where code lives before being told.
  • You’re already invested in TypeScript and want the backend’s typing discipline to match a frontend built with it — see TypeScript Best Practices for the same discipline applied frontend-side.

A reasonable rule of thumb: if you can describe the service’s entire scope in one sentence and don’t expect that to change, plain Node is probably sufficient. If you’re describing a system — multiple resources, multiple consumers, a lifespan measured in years — Nest’s structure pays for itself well before you feel the pain of not having it.

Common Mistakes

  • Starting a long-lived, multi-engineer API in plain Express “to move fast,” then reinventing an ad hoc version of modules, DI, and decorators by hand a year in — usually worse than what Nest already provides.
  • Reaching for NestJS on a three-endpoint microservice and paying unnecessary ceremony and cold-start cost for structure you don’t need.
  • Using NestJS but ignoring its conventions — injecting nothing, putting logic directly in controllers — which gets you all the ceremony with none of the benefit.
  • Assuming the choice is permanent. Plenty of services start in Express and get rewritten in Nest once they justify the structure; migrating a well-factored Express service’s business logic into Nest’s modules isn’t a rewrite from scratch if the service and data layers were already separated.

FAQ

Can I use Express middleware inside NestJS? Yes — Nest runs on top of Express (or Fastify) by default and lets you use existing middleware directly, which makes migrating an existing Express app incrementally more realistic than a full rewrite.

Is NestJS slower than plain Express at runtime? Once a process is warm, the difference in request-handling overhead is small for typical CRUD workloads — the DI container resolves providers once at startup, not per-request. The overhead that matters is cold-start time, which is why it’s a more relevant factor for serverless than for a long-running server.

Does choosing NestJS commit me to TypeORM or a specific ORM? No — Nest is database-agnostic. TypeORM, Prisma, and Mongoose all have official or community integration modules; the dependency injection pattern shown above works the same regardless of which you pick.

For how the frontend should consume whichever backend you build, see REST API Integration Best Practices — and for how AI coding assistants factor into scaffolding either style of backend faster, AI Tools Every Modern Frontend Developer Should Know covers where that genuinely helps and where it doesn’t.

#Node.js#NestJS#Backend

Written by

Tayyab Roy

Senior Frontend / Full-Stack Engineer

Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.