← Back to DevBytes

When to Choose Express Over NestJS

When to Choose Express Over NestJS

Node.js has become one of the most popular runtimes for building backend applications, and with that popularity comes a wealth of frameworks. Two of the most prominent options are Express.js, the minimalist web framework that has been around since 2010, and NestJS, a modern, opinionated framework built on top of TypeScript and heavily inspired by Angular. While NestJS has gained significant traction for building scalable enterprise applications, Express remains the go-to choice for many developers and projects. Understanding when to choose one over the other is a critical skill for any Node.js developer.

What Is Express?

Express is a fast, unopinionated, minimalist web framework for Node.js. It provides a thin layer of fundamental web application features without obscuring Node.js features. Express gives you routing, middleware support, request and response handling, and not much else. Everything else — validation, authentication, database integration, dependency injection — is left up to you to assemble using third-party packages.

What Is NestJS?

NestJS is a progressive Node.js framework for building efficient, reliable, and scalable server-side applications. It is built with and fully supports TypeScript, and it combines elements of object-oriented programming, functional programming, and functional reactive programming. NestJS uses Express (or optionally Fastify) under the hood but wraps it in a heavily structured architecture with modules, controllers, providers, dependency injection, decorators, and pipes.

Why This Decision Matters

Choosing the wrong framework can have long-lasting consequences for your project. If you pick NestJS for a small prototype or a simple API, you may find yourself drowning in boilerplate, fighting the framework's conventions, and spending more time on architecture than on actual features. Conversely, if you pick Express for a large, complex enterprise application with dozens of developers, you may end up reinventing the wheel — building your own dependency injection system, module structure, and validation pipelines — resulting in an inconsistent codebase that is hard to onboard new developers into.

The decision also affects hiring, onboarding speed, performance characteristics, testing strategies, and long-term maintainability. Let us explore the scenarios where Express is the better choice.

When Express Is the Right Choice

1. Small to Medium-Sized Projects

If you are building a simple REST API, a webhook handler, a single-purpose microservice, or a prototype, Express is almost always the better choice. The overhead of setting up modules, providers, and decorators in NestJS is unnecessary for a project with a handful of endpoints.

const express = require('express');
const app = express();

app.use(express.json());

const users = [];

app.get('/users', (req, res) => {
  res.json(users);
});

app.post('/users', (req, res) => {
  const user = { id: users.length + 1, ...req.body };
  users.push(user);
  res.status(201).json(user);
});

app.listen(3000, () => {
  console.log('Server running on port 3000');
});

That is a complete, working API in under 20 lines of code. Achieving the same in NestJS requires generating a project, creating a module, a controller, a service, and wiring them together with decorators.

2. You Need Maximum Flexibility and Control

Express is unopinionated by design. It does not tell you how to structure your folders, how to handle dependency injection, or which ORM to use. If your team has strong opinions about architecture, or if you are integrating with an existing codebase that already has its own conventions, Express will not fight you.

This is particularly valuable when you are building something that does not fit the standard CRUD API mold — for example, a real-time streaming server, a custom GraphQL gateway, or an application that uses an unusual combination of libraries.

3. Performance-Critical Applications

While NestJS can use Fastify as its underlying HTTP engine, there is still overhead from the framework's abstraction layers — decorators, dependency resolution, interceptors, guards, and pipes all add processing time to each request. For raw throughput, a well-optimized Express application will generally outperform a NestJS application.

This matters in scenarios like high-frequency trading APIs, real-time gaming backends, or services that handle millions of requests per day where every millisecond counts.

4. Your Team Is More Comfortable with JavaScript Than TypeScript

NestJS is built around TypeScript and relies heavily on decorators and type metadata for its dependency injection and routing. While you can technically use NestJS with JavaScript, doing so is painful and defeats the purpose of the framework. Express, on the other hand, works equally well with plain JavaScript and TypeScript.

If your team is primarily JavaScript-focused, or if you are maintaining a legacy JavaScript codebase, Express is the natural fit.

5. Learning and Educational Projects

Express exposes you to the raw mechanics of HTTP handling in Node.js. You work directly with request and response objects, you manually wire up middleware, and you see exactly how data flows through your application. This makes Express an excellent choice for learning how Node.js actually works.

NestJS abstracts so much of this away that a developer can build a complete API without ever understanding what a middleware function is or how the request lifecycle works. For educational purposes, that opacity is a disadvantage.

6. Serverless and Edge Deployments

When deploying to serverless platforms like AWS Lambda, Vercel Functions, or Cloudflare Workers, cold start time and bundle size matter enormously. NestJS applications tend to have larger bundle sizes and longer cold starts due to the framework's initialization process — loading modules, resolving dependencies, and setting up the DI container.

Express, being lightweight, starts faster and produces smaller bundles. Many serverless-focused frameworks and libraries are also built with Express compatibility in mind.

// serverless.js — Express handler for AWS Lambda
const serverless = require('serverless-http');
const express = require('express');
const app = express();

app.get('/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.get('/products/:id', (req, res) => {
  res.json({ id: req.params.id, name: 'Sample Product' });
});

module.exports.handler = serverless(app);

When NestJS Is the Better Choice (For Contrast)

To make an informed decision, it helps to understand when NestJS wins. You should lean toward NestJS when:

How to Structure an Express Application for Scalability

One of the main criticisms of Express is that it does not scale well without discipline. However, with a few conventions, you can build a highly maintainable Express application. The key is to adopt a modular structure and keep your route handlers thin.

Project Structure

src/
  modules/
    users/
      users.controller.js
      users.service.js
      users.routes.js
      users.validation.js
    products/
      products.controller.js
      products.service.js
      products.routes.js
      products.validation.js
  middleware/
    auth.js
    errorHandler.js
    validate.js
  config/
    database.js
    env.js
  app.js
  server.js

Example: Modular Express Application

Here is how you can build a clean, modular Express application that rivals NestJS in structure while retaining Express's simplicity.

src/modules/users/users.service.js

const users = [];

class UsersService {
  findAll() {
    return users;
  }

  findById(id) {
    return users.find(u => u.id === parseInt(id));
  }

  create(data) {
    const user = { id: users.length + 1, ...data };
    users.push(user);
    return user;
  }

  update(id, data) {
    const user = this.findById(id);
    if (!user) return null;
    Object.assign(user, data);
    return user;
  }

  delete(id) {
    const index = users.findIndex(u => u.id === parseInt(id));
    if (index === -1) return false;
    users.splice(index, 1);
    return true;
  }
}

module.exports = new UsersService();

src/modules/users/users.validation.js

const { body, validationResult } = require('express-validator');

const createUserRules = [
  body('name').isString().isLength({ min: 2 }).withMessage('Name must be at least 2 characters'),
  body('email').isEmail().withMessage('Valid email is required'),
];

const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  next();
};

module.exports = { createUserRules, validate };

src/modules/users/users.controller.js

const usersService = require('./users.service');

class UsersController {
  getAll(req, res) {
    res.json(usersService.findAll());
  }

  getOne(req, res) {
    const user = usersService.findById(req.params.id);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json(user);
  }

  create(req, res) {
    const user = usersService.create(req.body);
    res.status(201).json(user);
  }

  update(req, res) {
    const user = usersService.update(req.params.id, req.body);
    if (!user) return res.status(404).json({ error: 'User not found' });
    res.json(user);
  }

  remove(req, res) {
    const deleted = usersService.delete(req.params.id);
    if (!deleted) return res.status(404).json({ error: 'User not found' });
    res.status(204).send();
  }
}

module.exports = new UsersController();

src/modules/users/users.routes.js

const express = require('express');
const router = express.Router();
const controller = require('./users.controller');
const { createUserRules, validate } = require('./users.validation');

router.get('/', controller.getAll);
router.get('/:id', controller.getOne);
router.post('/', createUserRules, validate, controller.create);
router.put('/:id', controller.update);
router.delete('/:id', controller.remove);

module.exports = router;

src/app.js

const express = require('express');
const usersRoutes = require('./modules/users/users.routes');
const errorHandler = require('./middleware/errorHandler');

const app = express();

app.use(express.json());

app.use('/users', usersRoutes);

app.use(errorHandler);

module.exports = app;

src/server.js

const app = require('./app');

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

As you can see, this structure provides clear separation of concerns, is easy to test, and scales well as you add more modules. You get many of the benefits of NestJS without the framework overhead.

Best Practices When Using Express

Use a Consistent Module Pattern

As shown above, adopt a consistent pattern of controller, service, routes, and validation for every module. This consistency is what makes a codebase maintainable, not the framework itself.

Always Use Error Handling Middleware

Express does not catch errors in async route handlers by default. Always wrap async handlers and use a centralized error handler.

// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Internal Server Error',
  });
}

// Async wrapper to catch promise rejections
const asyncHandler = (fn) => (req, res, next) => {
  Promise.resolve(fn(req, res, next)).catch(next);
};

module.exports = { errorHandler, asyncHandler };

Then use the async wrapper in your routes:

router.get('/', asyncHandler(controller.getAll));

Use Helmet and CORS for Security

const helmet = require('helmet');
const cors = require('cors');

app.use(helmet());
app.use(cors());

Validate Input Religiously

Use a validation library like express-validator, joi, or zod to validate every incoming request. Never trust client input.

Use Environment Variables for Configuration

require('dotenv').config();

const config = {
  port: process.env.PORT || 3000,
  dbUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
};

module.exports = config;

Write Tests Early

Express applications are straightforward to test using supertest. Write integration tests for your routes from the beginning.

const request = require('supertest');
const app = require('../src/app');

describe('Users API', () => {
  it('should create a new user', async () => {
    const res = await request(app)
      .post('/users')
      .send({ name: 'Jane Doe', email: 'jane@example.com' })
      .expect(201);

    expect(res.body).toHaveProperty('id');
    expect(res.body.name).toBe('Jane Doe');
  });

  it('should return 400 for invalid input', async () => {
    const res = await request(app)
      .post('/users')
      .send({ name: 'J' })
      .expect(400);

    expect(res.body).toHaveProperty('errors');
  });
});

Consider Using TypeScript

Even if you choose Express over NestJS, you can still benefit from TypeScript. Express works well with TypeScript, and you get type safety without the architectural overhead of NestJS.

import express, { Request, Response, NextFunction } from 'express';

const app = express();

interface User {
  id: number;
  name: string;
  email: string;
}

const users: User[] = [];

app.post('/users', (req: Request, res: Response) => {
  const { name, email } = req.body;
  const user: User = { id: users.length + 1, name, email };
  users.push(user);
  res.status(201).json(user);
});

app.listen(3000);

Performance Comparison

To give you a sense of the practical difference, here is a simplified comparison of what happens during a typical request in each framework.

In Express, a request flows through a chain of middleware functions you explicitly define, hits your route handler, and returns. There is minimal abstraction between the incoming HTTP request and your code.

In NestJS, a request passes through middleware, guards, interceptors (pre-controller), pipes, the controller method, interceptors (post-controller), exception filters, and then back out. Each of these layers involves reflection, decorator metadata lookup, and dependency resolution. While NestJS is not slow by any means, these layers add measurable overhead.

For most applications, this difference is negligible. But if you are building a service that handles tens of thousands of requests per second, the cumulative effect is significant.

Migration Considerations

If you start with Express and later find that you need more structure, migrating to NestJS is possible but non-trivial. NestJS has its own way of doing things, and a direct port will require rethinking your architecture. This is why it is important to make the right choice early.

A pragmatic middle ground is to start with Express using a modular structure (as shown above) and adopt additional libraries as needed — a DI container like awilix or inversify, a validation library, and a testing framework. You can build much of what NestJS provides yourself, incrementally, only when you actually need it.

Conclusion

Choosing between Express and NestJS is not about which framework is objectively better — it is about which one fits your project, your team, and your constraints. Express shines when you need simplicity, flexibility, raw performance, a small bundle size, or full control over your architecture. It is ideal for small to medium projects, serverless functions, prototypes, educational endeavors, and teams that prefer a minimalist approach. NestJS, on the other hand, excels in large enterprise applications where enforced structure, dependency injection, and a rich ecosystem of integrations save more time than they cost. The best developers understand both tools and make their choice based on the specific needs of the project rather than on hype or habit. By starting with a clear understanding of your application's scope, performance requirements, and team capabilities, you can confidently choose Express when it is the right tool for the job and build a clean, maintainable, and performant application with it.

— Ad —

Google AdSense will appear here after approval

← Back to all articles