nodejs|April 02, 2026|5 min read

Building REST APIs with Express and Nest.js

TL;DR

Express gives you minimal, flexible routing with middleware chains. Nest.js adds structure with decorators, modules, and dependency injection. Both can build production APIs — Express for simplicity, Nest.js for large teams and complex domains.

Two Paths to Building APIs

Express.js and Nest.js represent two philosophies for building Node.js APIs. Express gives you a minimal foundation and lets you choose your own structure. Nest.js provides an opinionated framework with built-in patterns borrowed from Angular.

Both are battle-tested in production — the right choice depends on your team size, project complexity, and preference for convention vs freedom.

Express.js — The Minimal Approach

Basic Server Setup

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

const app = express();

// Global middleware
app.use(helmet());              // Security headers
app.use(cors());                // CORS handling
app.use(express.json());        // Parse JSON bodies
app.use(express.urlencoded({ extended: true }));

// Routes
app.use('/api/users', require('./routes/users'));
app.use('/api/products', require('./routes/products'));

// 404 handler
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

// Global error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Internal Server Error'
  });
});

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

The Middleware Pipeline

Every request flows through a chain of middleware functions before reaching the route handler. Each middleware calls next() to pass control to the next function in the chain.

Express Middleware Pipeline

Building CRUD Routes

const express = require('express');
const router = express.Router();
const { validate } = require('../middleware/validate');
const { createUserSchema, updateUserSchema } = require('../schemas/user');

// GET /api/users
router.get('/', async (req, res, next) => {
  try {
    const { page = 1, limit = 20 } = req.query;
    const users = await UserService.findAll({
      page: parseInt(page),
      limit: parseInt(limit)
    });
    res.json(users);
  } catch (err) {
    next(err);
  }
});

// GET /api/users/:id
router.get('/:id', async (req, res, next) => {
  try {
    const user = await UserService.findById(req.params.id);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json(user);
  } catch (err) {
    next(err);
  }
});

// POST /api/users
router.post('/', validate(createUserSchema), async (req, res, next) => {
  try {
    const user = await UserService.create(req.body);
    res.status(201).json(user);
  } catch (err) {
    next(err);
  }
});

// PUT /api/users/:id
router.put('/:id', validate(updateUserSchema), async (req, res, next) => {
  try {
    const user = await UserService.update(req.params.id, req.body);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }
    res.json(user);
  } catch (err) {
    next(err);
  }
});

// DELETE /api/users/:id
router.delete('/:id', async (req, res, next) => {
  try {
    await UserService.delete(req.params.id);
    res.status(204).send();
  } catch (err) {
    next(err);
  }
});

module.exports = router;

Request Validation with Zod

const { z } = require('zod');

const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(2).max(100),
    email: z.string().email(),
    password: z.string().min(8).max(128),
    role: z.enum(['user', 'admin']).default('user'),
  })
});

// Validation middleware factory
function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse({
      body: req.body,
      query: req.query,
      params: req.params,
    });

    if (!result.success) {
      return res.status(400).json({
        error: 'Validation failed',
        details: result.error.issues.map(i => ({
          path: i.path.join('.'),
          message: i.message,
        })),
      });
    }

    // Replace with parsed (and coerced) data
    req.body = result.data.body;
    next();
  };
}

Nest.js — The Structured Approach

Nest.js uses TypeScript decorators, modules, and dependency injection to enforce a consistent architecture across large codebases.

Module Architecture

Nest.js Module Architecture

Project Setup

npm i -g @nestjs/cli
nest new my-api

Creating a Module with Controller and Service

// users.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { User } from './entities/user.entity';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService], // Available to other modules
})
export class UsersModule {}

Controller with Decorators

// users.controller.ts
import {
  Controller, Get, Post, Put, Delete,
  Body, Param, Query, HttpCode, HttpStatus,
  ParseIntPipe, ValidationPipe
} from '@nestjs/common';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { PaginationDto } from '../common/dto/pagination.dto';

@Controller('api/users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  findAll(@Query(ValidationPipe) pagination: PaginationDto) {
    return this.usersService.findAll(pagination);
  }

  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOne(id);
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  create(@Body(ValidationPipe) createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Put(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body(ValidationPipe) updateUserDto: UpdateUserDto,
  ) {
    return this.usersService.update(id, updateUserDto);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  remove(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.remove(id);
  }
}

Service with Dependency Injection

// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private usersRepository: Repository<User>,
  ) {}

  async findAll({ page, limit }: { page: number; limit: number }) {
    const [items, total] = await this.usersRepository.findAndCount({
      skip: (page - 1) * limit,
      take: limit,
      order: { createdAt: 'DESC' },
    });

    return {
      items,
      total,
      page,
      totalPages: Math.ceil(total / limit),
    };
  }

  async findOne(id: number): Promise<User> {
    const user = await this.usersRepository.findOne({ where: { id } });
    if (!user) {
      throw new NotFoundException(`User #${id} not found`);
    }
    return user;
  }

  async create(dto: CreateUserDto): Promise<User> {
    const user = this.usersRepository.create(dto);
    return this.usersRepository.save(user);
  }

  async update(id: number, dto: Partial<CreateUserDto>): Promise<User> {
    const user = await this.findOne(id);
    Object.assign(user, dto);
    return this.usersRepository.save(user);
  }

  async remove(id: number): Promise<void> {
    const user = await this.findOne(id);
    await this.usersRepository.remove(user);
  }
}

DTOs with class-validator

// dto/create-user.dto.ts
import { IsString, IsEmail, MinLength, MaxLength, IsEnum, IsOptional } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(2)
  @MaxLength(100)
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  @MaxLength(128)
  password: string;

  @IsEnum(['user', 'admin'])
  @IsOptional()
  role?: string = 'user';
}

API Versioning

Express — URL Prefix

const v1Router = express.Router();
const v2Router = express.Router();

v1Router.get('/users', v1UserController.list);
v2Router.get('/users', v2UserController.list); // New response format

app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);

Nest.js — Built-in Versioning

// main.ts
app.enableVersioning({
  type: VersioningType.URI,
  prefix: 'api/v',
});

// controller
@Controller({ path: 'users', version: '2' })
export class UsersV2Controller { /* ... */ }

Express vs Nest.js — When to Use Each

Factor Express Nest.js
Learning curve Low Medium-High
Project structure You decide Convention-based
TypeScript Optional First-class
Team size Small teams, solo Large teams
Flexibility Maximum Opinionated
Boilerplate Minimal More upfront
Testing Manual setup Built-in DI makes it easy
Microservices DIY Built-in transport layer

Choose Express when you want maximum control, are building a simple API or microservice, or your team prefers choosing their own patterns.

Choose Nest.js when you have a large team, want enforced structure, are building a complex domain-driven application, or need built-in microservice support.

Production Checklist

Regardless of which framework you choose:

// Rate limiting
const rateLimit = require('express-rate-limit');
app.use('/api/', rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
}));

// Request ID for tracing
const { v4: uuid } = require('uuid');
app.use((req, res, next) => {
  req.id = req.headers['x-request-id'] || uuid();
  res.setHeader('x-request-id', req.id);
  next();
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, shutting down gracefully');
  server.close(() => {
    db.close();
    process.exit(0);
  });
});

Both Express and Nest.js can power production APIs serving millions of requests. The best framework is the one your team can maintain, test, and deploy confidently.

Related Posts

Latest Posts