Skip to main content

Building REST APIs with Node.js and Express

Published: May 24, 2026 Updated: August 30, 2026 Larry Qu 9 min read

REST (Representational State Transfer) isn’t a protocol — it’s an architectural style with a set of constraints. When followed, those constraints make your API intuitive for clients, easy to cache, and straightforward to version. When ignored, you end up with something that looks like REST but behaves like ad-hoc RPC over HTTP.

This article covers how to design and implement REST APIs in Node.js that are actually RESTful — correct resource modeling, consistent response envelopes, pagination, versioning, validation, and production-ready error handling.

REST Design Principles Before Writing Code

Before looking at Express code, the design decisions matter more than the implementation. Two common mistakes kill API usability:

Verbs in URL paths. REST resources are nouns. /api/getUsers and /api/createPost are wrong. The HTTP method already carries the verb — GET /api/users and POST /api/posts are correct.

Inconsistent response shapes. If a successful GET /users returns [...] and a POST /users returns { user: {...} } and a GET /users/:id returns {...}, clients have to special-case every endpoint. A consistent envelope eliminates that.

The canonical resource mapping:

Method Path Semantics Response
GET /users List all users (paginated) 200 + array
POST /users Create a user 201 + created resource
GET /users/:id Get one user 200 + resource, or 404
PUT /users/:id Replace user 200 + updated resource
PATCH /users/:id Partial update 200 + updated resource
DELETE /users/:id Delete user 204 no body

Consistent Response Envelope

Define a response helper once and use it everywhere. This prevents every route handler from inventing its own response shape:

// src/lib/response.js
const success = (res, data, { status = 200, message = null, meta = null } = {}) => {
  const body = { success: true };
  if (message) body.message = message;
  if (meta)    body.meta    = meta;
  body.data = data;
  return res.status(status).json(body);
};

const created = (res, data) => success(res, data, { status: 201 });

const noContent = (res) => res.status(204).send();

const error = (res, message, { status = 500, code = null, details = null } = {}) => {
  const body = { success: false, error: { message } };
  if (code)    body.error.code    = code;
  if (details) body.error.details = details;
  return res.status(status).json(body);
};

module.exports = { success, created, noContent, error };

Successful response shape:

{ "success": true, "data": { "id": 1, "name": "Alice" } }

Error response shape:

{ "success": false, "error": { "message": "Email already in use", "code": "DUPLICATE_EMAIL" } }

Input Validation with express-validator

Never trust client input. Validate before it touches your business logic or database. express-validator lets you declare validation rules as middleware, keeping them separate from your handler logic.

Install it:

npm install express express-validator

A reusable validation middleware:

// src/middleware/validate.js
const { validationResult } = require('express-validator');
const { error } = require('../lib/response');

const validate = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return error(res, 'Validation failed', {
      status: 400,
      code: 'VALIDATION_ERROR',
      details: errors.array().map(e => ({ field: e.path, message: e.msg }))
    });
  }
  next();
};

module.exports = validate;

Validation error response tells clients exactly what’s wrong and where:

{
  "success": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Validation failed",
    "details": [
      { "field": "email", "message": "Must be a valid email address" },
      { "field": "password", "message": "Must be at least 8 characters" }
    ]
  }
}

Pagination

Returning all records from a collection endpoint is a performance problem waiting to happen. Implement cursor-based or offset-based pagination from the start — adding it later requires changing the response shape.

Offset pagination is simpler to implement and works well for most use cases:

// src/lib/pagination.js
const parsePagination = (query, { defaultLimit = 20, maxLimit = 100 } = {}) => {
  const page  = Math.max(1, parseInt(query.page, 10)  || 1);
  const limit = Math.min(maxLimit, Math.max(1, parseInt(query.limit, 10) || defaultLimit));
  const skip  = (page - 1) * limit;
  return { page, limit, skip };
};

const paginationMeta = (total, { page, limit }) => ({
  page,
  limit,
  total,
  totalPages: Math.ceil(total / limit),
  hasNext: page < Math.ceil(total / limit),
  hasPrev: page > 1,
});

module.exports = { parsePagination, paginationMeta };

A paginated list handler:

const { parsePagination, paginationMeta } = require('../lib/pagination');
const { success } = require('../lib/response');
const User = require('../models/User');

const listUsers = async (req, res, next) => {
  try {
    const { page, limit, skip } = parsePagination(req.query);
    const filter = {};
    if (req.query.role) filter.role = req.query.role;

    // Run count and fetch in parallel for efficiency
    const [total, users] = await Promise.all([
      User.countDocuments(filter),
      User.find(filter).sort({ createdAt: -1 }).skip(skip).limit(limit).lean()
    ]);

    return success(res, users, {
      meta: paginationMeta(total, { page, limit })
    });
  } catch (err) {
    next(err);
  }
};

Paginated response shape:

{
  "success": true,
  "data": [...],
  "meta": {
    "page": 2, "limit": 20, "total": 183,
    "totalPages": 10, "hasNext": true, "hasPrev": true
  }
}

Complete Resource Controller

Putting it together — a full CRUD controller with validation, pagination, and consistent responses:

// src/controllers/userController.js
const { body } = require('express-validator');
const { success, created, noContent, error } = require('../lib/response');
const { parsePagination, paginationMeta } = require('../lib/pagination');
const User = require('../models/User');

// Validation rule sets (reusable across create/update)
const createRules = [
  body('name').trim().notEmpty().withMessage('Name is required'),
  body('email').isEmail().normalizeEmail().withMessage('Must be a valid email'),
  body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
];

const updateRules = [
  body('name').optional().trim().notEmpty().withMessage('Name cannot be empty'),
  body('email').optional().isEmail().normalizeEmail(),
];

const list = async (req, res, next) => {
  try {
    const { page, limit, skip } = parsePagination(req.query);
    const filter = {};
    if (req.query.role) filter.role = req.query.role;

    const [total, users] = await Promise.all([
      User.countDocuments(filter),
      User.find(filter).select('-password').sort({ createdAt: -1 }).skip(skip).limit(limit).lean()
    ]);

    return success(res, users, { meta: paginationMeta(total, { page, limit }) });
  } catch (err) { next(err); }
};

const getOne = async (req, res, next) => {
  try {
    const user = await User.findById(req.params.id).select('-password').lean();
    if (!user) return error(res, 'User not found', { status: 404, code: 'NOT_FOUND' });
    return success(res, user);
  } catch (err) { next(err); }
};

const create = async (req, res, next) => {
  try {
    const user = await User.create(req.body);
    const { password: _, ...safeUser } = user.toObject();
    return created(res, safeUser);
  } catch (err) {
    if (err.code === 11000) {
      return error(res, 'Email already in use', { status: 409, code: 'DUPLICATE_EMAIL' });
    }
    next(err);
  }
};

const update = async (req, res, next) => {
  try {
    const user = await User.findByIdAndUpdate(req.params.id, req.body, {
      new: true, runValidators: true, select: '-password'
    }).lean();
    if (!user) return error(res, 'User not found', { status: 404 });
    return success(res, user);
  } catch (err) { next(err); }
};

const remove = async (req, res, next) => {
  try {
    const user = await User.findByIdAndDelete(req.params.id);
    if (!user) return error(res, 'User not found', { status: 404 });
    return noContent(res);
  } catch (err) { next(err); }
};

module.exports = { list, getOne, create, update, remove, createRules, updateRules };

Router and App Wiring

The router layer is thin — just route-to-handler mapping with middleware:

// src/routes/users.js
const express = require('express');
const ctrl = require('../controllers/userController');
const validate = require('../middleware/validate');
const auth = require('../middleware/auth');

const router = express.Router();

router.get('/',     ctrl.list);
router.post('/',    ctrl.createRules, validate, ctrl.create);
router.get('/:id',  ctrl.getOne);
router.put('/:id',  auth, ctrl.updateRules, validate, ctrl.update);
router.delete('/:id', auth, ctrl.remove);

module.exports = router;
// src/app.js
const express = require('express');
const userRoutes = require('./routes/users');
const errorHandler = require('./middleware/errorHandler');

const app = express();
app.use(express.json({ limit: '10kb' }));   // Prevent oversized request bodies

app.use('/api/v1/users', userRoutes);

// Centralized error handler — must be last
app.use(errorHandler);

module.exports = app;

Centralized Error Handler

Express’s four-argument error handler catches anything passed to next(err):

// src/middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
  // Log with enough context to debug, but don't expose internals to clients
  console.error({
    message: err.message,
    stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
    url: req.url,
    method: req.method,
  });

  // Mongoose validation errors
  if (err.name === 'ValidationError') {
    const details = Object.values(err.errors).map(e => ({
      field: e.path, message: e.message
    }));
    return res.status(400).json({
      success: false, error: { code: 'VALIDATION_ERROR', message: 'Validation failed', details }
    });
  }

  // Invalid MongoDB ObjectId
  if (err.name === 'CastError') {
    return res.status(400).json({
      success: false, error: { message: 'Invalid ID format' }
    });
  }

  const status = err.status ?? err.statusCode ?? 500;
  const message = status < 500 ? err.message : 'Internal server error';

  res.status(status).json({ success: false, error: { message } });
};

module.exports = errorHandler;

The key principle: log the full error internally, but send sanitized messages to clients. Stack traces and internal system details should never appear in API responses.

API Versioning

Version your API from day one, even if you only have v1. URL versioning (/api/v1/...) is the most explicit and widely supported approach — clients know exactly what version they’re calling, and you can run multiple versions simultaneously during transitions.

// src/app.js — multiple versions in parallel
const v1Users = require('./routes/v1/users');
const v2Users = require('./routes/v2/users');

app.use('/api/v1/users', v1Users);
app.use('/api/v2/users', v2Users);

When deprecating a version, add Deprecation and Sunset headers so clients know when to migrate:

const deprecationWarning = (req, res, next) => {
  res.set('Deprecation', 'true');
  res.set('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
  res.set('Link', '</api/v2/users>; rel="successor-version"');
  next();
};

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

API Documentation with OpenAPI

Document endpoints using JSDoc comments that swagger-jsdoc can parse. This keeps documentation close to the code and generates interactive Swagger UI automatically:

npm install swagger-jsdoc swagger-ui-express
// src/app.js — attach Swagger UI
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');

const spec = swaggerJsdoc({
  definition: {
    openapi: '3.0.0',
    info: { title: 'Users API', version: '1.0.0' },
    servers: [{ url: '/api/v1' }]
  },
  apis: ['./src/routes/*.js']
});

app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec));
// In your route file, annotate each handler:
/**
 * @swagger
 * /users:
 *   get:
 *     summary: List users
 *     parameters:
 *       - { in: query, name: page,  schema: { type: integer }, description: Page number }
 *       - { in: query, name: limit, schema: { type: integer }, description: Items per page }
 *     responses:
 *       200:
 *         description: Paginated user list
 */

Summary

A well-designed REST API is a contract. Clients depend on it behaving predictably. The key principles:

  • Use nouns in paths and HTTP methods for verbs
  • Return a consistent response envelope from every endpoint
  • Validate input with express-validator before it reaches business logic
  • Implement pagination from the start — default to 20 items, cap at 100
  • Handle errors in one place with the Express error handler
  • Version from the beginning with URL versioning
  • Strip sensitive fields (passwords, tokens) before sending responses

Resources

Comments

👍 Was this article helpful?