Building a REST API with Node.js and Express: A Complete Guide

A complete, practical guide to structuring and building a production-ready REST API with Node.js and Express.

Project Structure

src/
  routes/
  controllers/
  middleware/
  models/
  services/
  app.js
  server.js

Setting Up Express

// app.js
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');

const app = express();
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGIN }));
app.use(express.json());

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

module.exports = app;

Defining Routes and Controllers

// routes/products.js
const router = require('express').Router();
const controller = require('../controllers/productController');

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

module.exports = router;
// controllers/productController.js
exports.list = async (req, res, next) => {
  try {
    const products = await ProductService.findAll(req.query);
    res.json(products);
  } catch (err) {
    next(err);
  }
};

exports.create = async (req, res, next) => {
  try {
    const product = await ProductService.create(req.body);
    res.status(201).json(product);
  } catch (err) {
    next(err);
  }
};

Centralized Error Handling

// middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  const status = err.statusCode || 500;
  res.status(status).json({
    error: err.message || 'Internal server error',
  });
}

module.exports = errorHandler;
// app.js (added at the end, after routes)
app.use(require('./middleware/errorHandler'));

Input Validation

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

const productSchema = z.object({
  name: z.string().min(1),
  price: z.number().positive(),
});

function validate(schema) {
  return (req, res, next) => {
    const result = schema.safeParse(req.body);
    if (!result.success) return res.status(400).json(result.error);
    req.body = result.data;
    next();
  };
}

router.post('/', validate(productSchema), controller.create);

Pagination

exports.list = async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 20;

  const products = await Product.find()
    .skip((page - 1) * limit)
    .limit(limit);

  res.json({ data: products, page, limit });
};

Conclusion

A clean Express API comes down to consistent structure: routes stay thin, controllers handle request/response shaping, services hold business logic, and a single error-handling middleware catches everything. This separation keeps the codebase testable and predictable as it grows.