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:
- Resources are nouns, not verbs. Use
/books, not/getBooks. - HTTP methods express the action.
GETreads,POSTcreates,PUTreplaces,PATCHupdates partially,DELETEremoves. - Plural nouns for collections.
/booksfor the collection,/books/:idfor a single resource. - Nested resources for relationships.
/authors/:authorId/bookswhen the relationship is meaningful. - 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/:idreq.query— Query string values like?page=2&limit=10req.body— Parsed JSON or form data fromPOST/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
Separate app creation from server startup. Keep
app.jsandserver.jsseparate so tests can import the app without binding to a port.Validate all input at the boundary. Never trust data from
req.body,req.query, orreq.params. Use express-validator or Joi to validate and sanitize before it reaches your business logic.Use a centralized error handler. Scattering
try/catchblocks with inlineres.status(500).json(...)calls across every route is unmaintainable. Throw errors and let the middleware handle formatting.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.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.Set security headers with Helmet. It takes one line of code and closes a dozen common attack vectors including XSS, clickjacking, and MIME sniffing.
Log requests in production. Morgan with the
combinedformat gives you IP, timestamp, method, path, status code, response time, and user agent. Ship these logs to a centralized service for monitoring and debugging.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.
Use appropriate HTTP status codes. A
200for everything technically works, but it makes client-side error handling a nightmare. The status code is metadata — use it correctly.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.