Nodejs

Building REST APIs with Express.js: Complete Guide

A complete guide to building production-ready REST APIs with Express.js covering CRUD operations, validation, authentication, pagination, error handling, and testing.

Building REST APIs with Express.js: Complete Guide

Express.js remains the backbone of Node.js API development for good reason. It is minimal enough to stay out of your way, yet flexible enough to support production workloads serving millions of requests. After building dozens of REST APIs with Express over the past decade, I have landed on a set of patterns that consistently produce maintainable, testable, and performant services.

This guide walks through every layer of a production REST API — from route organization and request parsing to authentication, pagination, rate limiting, and automated testing. By the end, you will have a complete working API for a "books" resource that you can copy, extend, and deploy.

Prerequisites

  • Node.js v18+ installed
  • Basic familiarity with JavaScript and HTTP
  • A terminal and text editor
  • MongoDB or any database (examples use an in-memory store for portability)

Initialize a project and install the dependencies we will use throughout:

mkdir books-api && cd books-api
npm init -y
npm install express express-validator jsonwebtoken bcryptjs cors helmet morgan express-rate-limit swagger-ui-express uuid
npm install --save-dev supertest jest

REST Principles and Resource Naming

REST is not a protocol — it is a set of architectural constraints. The ones that matter most for API design are:

  1. Resources are nouns, not verbs. Use /books, not /getBooks.
  2. HTTP methods express the action. GET reads, POST creates, PUT replaces, PATCH updates partially, DELETE removes.
  3. Plural nouns for collections. /books for the collection, /books/:id for a single resource.
  4. Nested resources for relationships. /authors/:authorId/books when the relationship is meaningful.
  5. Query parameters for filtering, sorting, and pagination. /books?genre=fiction&sort=-publishedDate&page=2

Avoid these common naming mistakes:

Bad Good Why
/getBooks GET /books The method is the verb
/book /books Collections are plural
/books/delete/5 DELETE /books/5 Use HTTP methods
/Books /books Lowercase, hyphenated

Project Structure

A flat file structure works for tiny APIs. Anything beyond a handful of routes needs organization. This structure scales well:

books-api/
  src/
    app.js              # Express app setup (no listen)
    server.js           # Starts the server
    config/
      index.js          # Environment config
    routes/
      index.js          # Route aggregator
      books.js          # Book routes
      auth.js           # Auth routes
    middleware/
      auth.js           # JWT verification
      errorHandler.js   # Centralized error handler
      validate.js       # Validation runner
    validators/
      books.js          # Book validation rules
    controllers/
      books.js          # Book business logic
    models/
      book.js           # Data access layer
    utils/
      ApiError.js       # Custom error class
  tests/
    books.test.js       # API integration tests

Separating app.js from server.js is critical. It lets you import the app into test files without starting a listener on a port.

Setting Up the Express App

// src/app.js
var express = require('express');
var cors = require('cors');
var helmet = require('helmet');
var morgan = require('morgan');
var rateLimit = require('express-rate-limit');
var routes = require('./routes');
var errorHandler = require('./middleware/errorHandler');

var app = express();

// Security headers
app.use(helmet());

// CORS — configure for your consumers
app.use(cors({
  origin: ['http://localhost:3000', 'https://yourdomain.com'],
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400
}));

// Request logging
app.use(morgan('combined'));

// Body parsing
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));

// Rate limiting
var limiter = rateLimit({
  windowMs: 15 * 60 * 1000,  // 15 minutes
  max: 100,                    // 100 requests per window
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'Too many requests, please try again later.' }
});
app.use('/api/', limiter);

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

// Error handling (must be last)
app.use(errorHandler);

module.exports = app;
// src/server.js
var app = require('./app');
var config = require('./config');

app.listen(config.port, function () {
  console.log('Books API listening on port ' + config.port);
});
// src/config/index.js
module.exports = {
  port: process.env.PORT || 3000,
  jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production',
  jwtExpiry: '24h'
};

Route Organization

Group routes by resource and aggregate them in a central router:

// src/routes/index.js
var express = require('express');
var router = express.Router();
var bookRoutes = require('./books');
var authRoutes = require('./auth');

router.use('/auth', authRoutes);
router.use('/books', bookRoutes);

// Health check
router.get('/health', function (req, res) {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

module.exports = router;
// src/routes/books.js
var express = require('express');
var router = express.Router();
var controller = require('../controllers/books');
var auth = require('../middleware/auth');
var validate = require('../middleware/validate');
var validators = require('../validators/books');

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

module.exports = router;

Notice the middleware chain: authentication first, then validation, then the controller. Each layer either passes control forward or short-circuits with an error response.

Request Parsing: Body, Params, and Query

Express gives you three primary sources of request data:

  • req.params — URL path segments like /books/:id
  • req.query — Query string values like ?page=2&limit=10
  • req.body — Parsed JSON or form data from POST/PUT/PATCH
// Example: all three in one handler
router.get('/:id/reviews', function (req, res) {
  var bookId = req.params.id;       // from URL path
  var page = req.query.page || 1;   // from query string
  // req.body is typically empty for GET requests
  console.log('Book: ' + bookId + ', Page: ' + page);
});

Always validate and sanitize input. Never trust req.body or req.query — they come directly from the client.

Response Formatting

I prefer a consistent JSON envelope for all responses. It makes life easier for frontend developers and simplifies error handling on the client side:

// Success response
{
  "success": true,
  "data": { /* resource or array */ },
  "meta": { "page": 1, "limit": 20, "total": 142 }
}

// Error response
{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Title is required",
    "details": [ /* field-level errors */ ]
  }
}

Use the correct HTTP status codes. The most common ones for REST APIs:

Code Meaning When to Use
200 OK Successful GET, PUT, PATCH
201 Created Successful POST that creates a resource
204 No Content Successful DELETE
400 Bad Request Validation errors, malformed JSON
401 Unauthorized Missing or invalid authentication
403 Forbidden Authenticated but lacks permission
404 Not Found Resource does not exist
409 Conflict Duplicate resource (e.g., unique constraint)
429 Too Many Requests Rate limit exceeded
500 Internal Server Error Unhandled server errors

The Custom Error Class

A custom error class lets you throw meaningful errors anywhere in your code and have the centralized handler format them consistently:

// src/utils/ApiError.js
function ApiError(statusCode, code, message, details) {
  Error.call(this, message);
  this.name = 'ApiError';
  this.statusCode = statusCode;
  this.code = code;
  this.message = message;
  this.details = details || null;
}

ApiError.prototype = Object.create(Error.prototype);
ApiError.prototype.constructor = ApiError;

ApiError.badRequest = function (message, details) {
  return new ApiError(400, 'BAD_REQUEST', message, details);
};

ApiError.notFound = function (message) {
  return new ApiError(404, 'NOT_FOUND', message || 'Resource not found');
};

ApiError.unauthorized = function (message) {
  return new ApiError(401, 'UNAUTHORIZED', message || 'Authentication required');
};

ApiError.conflict = function (message) {
  return new ApiError(409, 'CONFLICT', message);
};

module.exports = ApiError;

Centralized Error Handler

This is the single most important middleware in your API. Every error flows through here:

// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
  // Log the full error for debugging
  console.error('Error:', err);

  // Handle known API errors
  if (err.name === 'ApiError') {
    return res.status(err.statusCode).json({
      success: false,
      error: {
        code: err.code,
        message: err.message,
        details: err.details
      }
    });
  }

  // Handle JSON parse errors
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({
      success: false,
      error: {
        code: 'INVALID_JSON',
        message: 'Request body contains invalid JSON'
      }
    });
  }

  // Handle JWT errors
  if (err.name === 'JsonWebTokenError') {
    return res.status(401).json({
      success: false,
      error: {
        code: 'INVALID_TOKEN',
        message: 'Authentication token is invalid'
      }
    });
  }

  // Fallback for unexpected errors
  var statusCode = err.statusCode || 500;
  res.status(statusCode).json({
    success: false,
    error: {
      code: 'INTERNAL_ERROR',
      message: process.env.NODE_ENV === 'production'
        ? 'An unexpected error occurred'
        : err.message
    }
  });
}

module.exports = errorHandler;

The key insight: never send raw stack traces or internal error messages to clients in production. Log them server-side, but return sanitized messages.

Input Validation with express-validator

Validation belongs in its own layer — not scattered throughout controllers:

// src/validators/books.js
var { body, param, query } = require('express-validator');

var create = [
  body('title')
    .trim()
    .notEmpty().withMessage('Title is required')
    .isLength({ max: 200 }).withMessage('Title must be under 200 characters'),
  body('author')
    .trim()
    .notEmpty().withMessage('Author is required'),
  body('isbn')
    .optional()
    .isISBN().withMessage('Must be a valid ISBN'),
  body('genre')
    .optional()
    .isIn(['fiction', 'non-fiction', 'science', 'technology', 'history', 'biography'])
    .withMessage('Invalid genre'),
  body('publishedYear')
    .optional()
    .isInt({ min: 1000, max: 2030 }).withMessage('Invalid year'),
  body('pages')
    .optional()
    .isInt({ min: 1 }).withMessage('Pages must be a positive integer')
];

var update = [
  param('id').isUUID().withMessage('Invalid book ID'),
].concat(create);

var patch = [
  param('id').isUUID().withMessage('Invalid book ID'),
  body('title').optional().trim().isLength({ max: 200 }),
  body('author').optional().trim(),
  body('isbn').optional().isISBN(),
  body('genre').optional().isIn(['fiction', 'non-fiction', 'science', 'technology', 'history', 'biography']),
  body('publishedYear').optional().isInt({ min: 1000, max: 2030 }),
  body('pages').optional().isInt({ min: 1 })
];

var list = [
  query('page').optional().isInt({ min: 1 }).toInt(),
  query('limit').optional().isInt({ min: 1, max: 100 }).toInt(),
  query('genre').optional().isIn(['fiction', 'non-fiction', 'science', 'technology', 'history', 'biography']),
  query('sort').optional().isIn(['title', '-title', 'publishedYear', '-publishedYear', 'createdAt', '-createdAt']),
  query('search').optional().trim().isLength({ max: 100 })
];

var getOne = [
  param('id').isUUID().withMessage('Invalid book ID')
];

module.exports = {
  create: create,
  update: update,
  patch: patch,
  list: list,
  getOne: getOne
};

The validation runner middleware checks for errors and formats them:

// src/middleware/validate.js
var { validationResult } = require('express-validator');

function validate(req, res, next) {
  var errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({
      success: false,
      error: {
        code: 'VALIDATION_ERROR',
        message: 'Request validation failed',
        details: errors.array().map(function (err) {
          return { field: err.path, message: err.msg };
        })
      }
    });
  }
  next();
}

module.exports = validate;

Data Model (In-Memory Store)

For this guide I am using an in-memory store. Swap it for MongoDB, PostgreSQL, or any database — the controller interface stays identical:

// src/models/book.js
var { v4: uuidv4 } = require('uuid');

var books = [];

// Seed some data
var seedBooks = [
  { title: 'Clean Code', author: 'Robert C. Martin', genre: 'technology', publishedYear: 2008, pages: 464 },
  { title: 'The Pragmatic Programmer', author: 'David Thomas', genre: 'technology', publishedYear: 1999, pages: 352 },
  { title: 'Designing Data-Intensive Applications', author: 'Martin Kleppmann', genre: 'technology', publishedYear: 2017, pages: 616 }
];

seedBooks.forEach(function (book) {
  books.push(Object.assign({}, book, {
    id: uuidv4(),
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  }));
});

function findAll(options) {
  var result = books.slice();

  // Filter by genre
  if (options.genre) {
    result = result.filter(function (b) { return b.genre === options.genre; });
  }

  // Search by title or author
  if (options.search) {
    var term = options.search.toLowerCase();
    result = result.filter(function (b) {
      return b.title.toLowerCase().indexOf(term) !== -1 ||
             b.author.toLowerCase().indexOf(term) !== -1;
    });
  }

  // Sort
  if (options.sort) {
    var desc = options.sort.charAt(0) === '-';
    var field = desc ? options.sort.substring(1) : options.sort;
    result.sort(function (a, b) {
      if (a[field] < b[field]) return desc ? 1 : -1;
      if (a[field] > b[field]) return desc ? -1 : 1;
      return 0;
    });
  }

  var total = result.length;
  var page = options.page || 1;
  var limit = options.limit || 20;
  var offset = (page - 1) * limit;
  var paged = result.slice(offset, offset + limit);

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

function findById(id) {
  return books.find(function (b) { return b.id === id; }) || null;
}

function create(data) {
  var book = Object.assign({}, data, {
    id: uuidv4(),
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString()
  });
  books.push(book);
  return book;
}

function update(id, data) {
  var index = books.findIndex(function (b) { return b.id === id; });
  if (index === -1) return null;
  books[index] = Object.assign({}, books[index], data, {
    id: id,
    updatedAt: new Date().toISOString()
  });
  return books[index];
}

function remove(id) {
  var index = books.findIndex(function (b) { return b.id === id; });
  if (index === -1) return false;
  books.splice(index, 1);
  return true;
}

// Reset for testing
function reset() {
  books.length = 0;
}

module.exports = {
  findAll: findAll,
  findById: findById,
  create: create,
  update: update,
  remove: remove,
  reset: reset
};

CRUD Controller

The controller handles business logic and delegates data operations to the model:

// src/controllers/books.js
var Book = require('../models/book');
var ApiError = require('../utils/ApiError');

function list(req, res, next) {
  try {
    var result = Book.findAll({
      page: req.query.page,
      limit: req.query.limit,
      genre: req.query.genre,
      sort: req.query.sort,
      search: req.query.search
    });
    res.json({ success: true, data: result.data, meta: result.meta });
  } catch (err) {
    next(err);
  }
}

function getOne(req, res, next) {
  try {
    var book = Book.findById(req.params.id);
    if (!book) {
      throw ApiError.notFound('Book not found');
    }
    res.json({ success: true, data: book });
  } catch (err) {
    next(err);
  }
}

function create(req, res, next) {
  try {
    var book = Book.create({
      title: req.body.title,
      author: req.body.author,
      isbn: req.body.isbn,
      genre: req.body.genre,
      publishedYear: req.body.publishedYear,
      pages: req.body.pages
    });
    res.status(201).json({ success: true, data: book });
  } catch (err) {
    next(err);
  }
}

function updateBook(req, res, next) {
  try {
    var book = Book.update(req.params.id, {
      title: req.body.title,
      author: req.body.author,
      isbn: req.body.isbn,
      genre: req.body.genre,
      publishedYear: req.body.publishedYear,
      pages: req.body.pages
    });
    if (!book) {
      throw ApiError.notFound('Book not found');
    }
    res.json({ success: true, data: book });
  } catch (err) {
    next(err);
  }
}

function patch(req, res, next) {
  try {
    var updates = {};
    ['title', 'author', 'isbn', 'genre', 'publishedYear', 'pages'].forEach(function (field) {
      if (req.body[field] !== undefined) {
        updates[field] = req.body[field];
      }
    });
    var book = Book.update(req.params.id, updates);
    if (!book) {
      throw ApiError.notFound('Book not found');
    }
    res.json({ success: true, data: book });
  } catch (err) {
    next(err);
  }
}

function remove(req, res, next) {
  try {
    var removed = Book.remove(req.params.id);
    if (!removed) {
      throw ApiError.notFound('Book not found');
    }
    res.status(204).send();
  } catch (err) {
    next(err);
  }
}

module.exports = {
  list: list,
  getOne: getOne,
  create: create,
  update: updateBook,
  patch: patch,
  remove: remove
};

Pagination Patterns

Two dominant approaches exist: offset-based and cursor-based.

Offset-based is simpler and works well for most APIs:

GET /api/books?page=3&limit=20

The response includes pagination metadata:

{
  "meta": {
    "page": 3,
    "limit": 20,
    "total": 142,
    "totalPages": 8
  }
}

Cursor-based is better for large, frequently changing datasets. Instead of a page number, the client passes the ID or timestamp of the last item it received:

GET /api/books?after=abc123&limit=20

The tradeoff: cursor-based pagination prevents skipping to arbitrary pages, but it never skips or duplicates records when data changes between requests. Use offset for admin dashboards and internal tools. Use cursor for feeds, timelines, and mobile infinite scroll.

Filtering and Sorting

Support filtering through query parameters. Keep the interface predictable:

GET /api/books?genre=technology&search=clean&sort=-publishedYear

The - prefix for descending sort is a widely adopted convention. Support multiple sort fields if your use case requires it, but a single sort field covers 90% of needs.

For more complex filtering, consider a dedicated filter syntax. But start simple — most APIs never need more than equality filters and a search term.

API Versioning

Three common strategies, in order of my preference:

1. URL path versioning (recommended for most projects):

/api/v1/books
/api/v2/books

Simple, explicit, easy to route. The version is visible in every request.

2. Header versioning:

Accept: application/vnd.myapi.v2+json

Cleaner URLs, but harder to test in a browser and easy to forget.

3. Query parameter versioning:

/api/books?version=2

Works but feels wrong. The version is not part of the resource identifier.

In Express, URL versioning is straightforward:

var v1Routes = require('./routes/v1');
var v2Routes = require('./routes/v2');

app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);

My advice: do not version until you need to. Start with /api/books. When you introduce breaking changes, add /api/v2/books and keep v1 running until consumers migrate.

Authentication Middleware (JWT)

A clean JWT middleware that protects routes:

// src/middleware/auth.js
var jwt = require('jsonwebtoken');
var config = require('../config');
var ApiError = require('../utils/ApiError');

function auth(req, res, next) {
  var header = req.headers.authorization;
  if (!header || !header.startsWith('Bearer ')) {
    return next(ApiError.unauthorized('No token provided'));
  }

  var token = header.split(' ')[1];
  try {
    var decoded = jwt.verify(token, config.jwtSecret);
    req.user = decoded;
    next();
  } catch (err) {
    next(ApiError.unauthorized('Invalid or expired token'));
  }
}

module.exports = auth;

A simple auth route for generating tokens:

// src/routes/auth.js
var express = require('express');
var router = express.Router();
var jwt = require('jsonwebtoken');
var bcrypt = require('bcryptjs');
var config = require('../config');

// In-memory user store (replace with database)
var users = [
  { id: '1', email: '[email protected]', passwordHash: bcrypt.hashSync('password123', 10), role: 'admin' }
];

router.post('/login', function (req, res) {
  var email = req.body.email;
  var password = req.body.password;

  var user = users.find(function (u) { return u.email === email; });
  if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
    return res.status(401).json({
      success: false,
      error: { code: 'AUTH_FAILED', message: 'Invalid email or password' }
    });
  }

  var token = jwt.sign(
    { id: user.id, email: user.email, role: user.role },
    config.jwtSecret,
    { expiresIn: config.jwtExpiry }
  );

  res.json({ success: true, data: { token: token, expiresIn: config.jwtExpiry } });
});

module.exports = router;

CORS for API Consumers

If your API serves a frontend on a different domain, CORS configuration is mandatory. The setup in our app.js handles the common case. For more granular control:

var corsOptions = {
  origin: function (origin, callback) {
    var allowedOrigins = ['https://myapp.com', 'https://staging.myapp.com'];
    // Allow requests with no origin (mobile apps, curl, Postman)
    if (!origin || allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true
};

app.use(cors(corsOptions));

API Documentation with Swagger/OpenAPI

Self-documenting APIs save everyone time. Here is a minimal Swagger setup:

// src/swagger.js
var swaggerUi = require('swagger-ui-express');

var swaggerDocument = {
  openapi: '3.0.0',
  info: {
    title: 'Books API',
    version: '1.0.0',
    description: 'A REST API for managing books'
  },
  servers: [{ url: '/api' }],
  components: {
    securitySchemes: {
      bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }
    },
    schemas: {
      Book: {
        type: 'object',
        properties: {
          id: { type: 'string', format: 'uuid' },
          title: { type: 'string' },
          author: { type: 'string' },
          isbn: { type: 'string' },
          genre: { type: 'string', enum: ['fiction', 'non-fiction', 'science', 'technology', 'history', 'biography'] },
          publishedYear: { type: 'integer' },
          pages: { type: 'integer' },
          createdAt: { type: 'string', format: 'date-time' },
          updatedAt: { type: 'string', format: 'date-time' }
        }
      }
    }
  },
  paths: {
    '/books': {
      get: {
        summary: 'List books',
        parameters: [
          { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } },
          { name: 'limit', in: 'query', schema: { type: 'integer', default: 20 } },
          { name: 'genre', in: 'query', schema: { type: 'string' } },
          { name: 'sort', in: 'query', schema: { type: 'string' } },
          { name: 'search', in: 'query', schema: { type: 'string' } }
        ],
        responses: { '200': { description: 'Paginated list of books' } }
      },
      post: {
        summary: 'Create a book',
        security: [{ bearerAuth: [] }],
        requestBody: { content: { 'application/json': { schema: { '$ref': '#/components/schemas/Book' } } } },
        responses: { '201': { description: 'Book created' } }
      }
    }
  }
};

function setupSwagger(app) {
  app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));
}

module.exports = setupSwagger;

Then add require('./swagger')(app) in your app.js before the error handler. Visit /api/docs for interactive documentation.

Testing API Endpoints with Supertest

Supertest lets you make HTTP requests against your Express app without starting a server:

// tests/books.test.js
var request = require('supertest');
var app = require('../src/app');
var jwt = require('jsonwebtoken');
var config = require('../src/config');
var Book = require('../src/models/book');

function getToken() {
  return jwt.sign(
    { id: '1', email: '[email protected]', role: 'admin' },
    config.jwtSecret,
    { expiresIn: '1h' }
  );
}

describe('Books API', function () {
  var token;
  var createdBookId;

  beforeAll(function () {
    token = getToken();
  });

  beforeEach(function () {
    Book.reset();
  });

  describe('GET /api/books', function () {
    test('returns a paginated list of books', function () {
      return request(app)
        .get('/api/books')
        .expect(200)
        .then(function (res) {
          expect(res.body.success).toBe(true);
          expect(Array.isArray(res.body.data)).toBe(true);
          expect(res.body.meta).toHaveProperty('total');
          expect(res.body.meta).toHaveProperty('page');
        });
    });

    test('filters books by genre', function () {
      // Seed a book first
      Book.create({ title: 'Test Fiction', author: 'Test', genre: 'fiction' });
      return request(app)
        .get('/api/books?genre=fiction')
        .expect(200)
        .then(function (res) {
          res.body.data.forEach(function (book) {
            expect(book.genre).toBe('fiction');
          });
        });
    });

    test('searches books by title', function () {
      Book.create({ title: 'Node.js in Action', author: 'Test', genre: 'technology' });
      return request(app)
        .get('/api/books?search=Node')
        .expect(200)
        .then(function (res) {
          expect(res.body.data.length).toBeGreaterThan(0);
        });
    });
  });

  describe('POST /api/books', function () {
    test('creates a book with valid data', function () {
      return request(app)
        .post('/api/books')
        .set('Authorization', 'Bearer ' + token)
        .send({ title: 'New Book', author: 'John Doe', genre: 'fiction' })
        .expect(201)
        .then(function (res) {
          expect(res.body.success).toBe(true);
          expect(res.body.data.title).toBe('New Book');
          expect(res.body.data.id).toBeDefined();
          createdBookId = res.body.data.id;
        });
    });

    test('returns 401 without authentication', function () {
      return request(app)
        .post('/api/books')
        .send({ title: 'New Book', author: 'John Doe' })
        .expect(401);
    });

    test('returns 400 with missing required fields', function () {
      return request(app)
        .post('/api/books')
        .set('Authorization', 'Bearer ' + token)
        .send({ genre: 'fiction' })
        .expect(400)
        .then(function (res) {
          expect(res.body.error.code).toBe('VALIDATION_ERROR');
        });
    });
  });

  describe('GET /api/books/:id', function () {
    test('returns 404 for non-existent book', function () {
      return request(app)
        .get('/api/books/00000000-0000-0000-0000-000000000000')
        .expect(404);
    });
  });

  describe('DELETE /api/books/:id', function () {
    test('deletes a book and returns 204', function () {
      var book = Book.create({ title: 'To Delete', author: 'Test' });
      return request(app)
        .delete('/api/books/' + book.id)
        .set('Authorization', 'Bearer ' + token)
        .expect(204);
    });
  });
});

Run the tests:

npx jest --verbose

Complete Working Example

Everything above assembles into a runnable project. Here is the directory listing and the package.json scripts section to tie it together:

{
  "scripts": {
    "start": "node src/server.js",
    "dev": "node --watch src/server.js",
    "test": "jest --verbose --forceExit"
  }
}

To get the API running:

npm start
# Test it:
curl http://localhost:3000/api/books
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","password":"password123"}'
# Use the returned token:
curl -X POST http://localhost:3000/api/books \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -d '{"title":"My Book","author":"Shane Larson","genre":"technology"}'

Common Issues and Troubleshooting

1. "Cannot POST /api/books" — Body Parser Not Registered

Express does not parse JSON bodies by default. You must add app.use(express.json()) before your routes. Order matters — middleware runs in the order it is registered.

2. CORS Preflight Failures

Browsers send an OPTIONS request before cross-origin POST/PUT/DELETE requests. If your CORS middleware is registered after your routes, the preflight request may not be handled. Always register CORS middleware early, before any route definitions.

3. Error Handler Not Catching Errors

Express error-handling middleware requires exactly four parameters: (err, req, res, next). If you omit the next parameter, Express treats it as regular middleware and it will never receive errors. Also ensure it is registered after all routes with app.use(errorHandler).

4. Async Errors Not Reaching the Error Handler

If you use Promises or async operations in route handlers, unhandled rejections bypass Express error handling. Either wrap your handlers in try/catch and call next(err), or use a wrapper function:

function asyncHandler(fn) {
  return function (req, res, next) {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

router.get('/books', asyncHandler(function (req, res) {
  // async operations here
}));

5. Rate Limiter Blocking Behind a Proxy

If your API runs behind a reverse proxy (Nginx, load balancer), the rate limiter sees all requests as coming from the same IP. Add app.set('trust proxy', 1) to use the X-Forwarded-For header.

Best Practices

  1. Separate app creation from server startup. Keep app.js and server.js separate so tests can import the app without binding to a port.

  2. Validate all input at the boundary. Never trust data from req.body, req.query, or req.params. Use express-validator or Joi to validate and sanitize before it reaches your business logic.

  3. Use a centralized error handler. Scattering try/catch blocks with inline res.status(500).json(...) calls across every route is unmaintainable. Throw errors and let the middleware handle formatting.

  4. Return consistent response shapes. Every endpoint should return the same JSON structure. Clients should not need to guess whether the response is { data: ... } or { result: ... } or a bare array.

  5. Version your API from the start (in the URL). Even if you only have v1, the /api/v1/ prefix costs nothing and saves painful migrations later.

  6. Set security headers with Helmet. It takes one line of code and closes a dozen common attack vectors including XSS, clickjacking, and MIME sniffing.

  7. Log requests in production. Morgan with the combined format gives you IP, timestamp, method, path, status code, response time, and user agent. Ship these logs to a centralized service for monitoring and debugging.

  8. Paginate every list endpoint. Never return unbounded result sets. Default to a reasonable page size (20 is common) and enforce a maximum limit (100) to prevent clients from requesting your entire database.

  9. Use appropriate HTTP status codes. A 200 for everything technically works, but it makes client-side error handling a nightmare. The status code is metadata — use it correctly.

  10. Write integration tests, not just unit tests. Supertest tests verify that your middleware chain, validation, auth, and controllers all work together. Unit tests for individual functions miss integration bugs.

References

Powered by Contentful