Request Validation and Structured Error Responses

A REST API is only as trustworthy as its input validation. Without validation, a single malformed request โ€” a missing field, a string where a number is expected, or a field longer than your database schema allows โ€” can crash a controller, corrupt stored data, or expose a security vulnerability. express-validator is the standard validation library for Express APIs: it provides a declarative, chainable API for validating and sanitising every field in request bodies, params, and query strings, and returns detailed field-level error messages that your React frontend can display next to the relevant form fields. This lesson shows you how to integrate it throughout your MERN blog API.

Why express-validator?

Feature Detail
Declarative rules Chain validation rules like body('email').isEmail().normalizeEmail()
Built-in sanitisers Trim whitespace, normalise email, escape HTML, convert to int
Field-level errors Each validation failure reports which field failed and why
Middleware-based Validation rules are Express middleware โ€” composable and reusable
No Mongoose coupling Validates at the HTTP layer before touching the database
Note: Validation should happen at the HTTP layer (express-validator) before reaching the database layer (Mongoose). This gives you two layers of protection: express-validator catches bad input before any database call is made, and Mongoose schema validators catch anything that slips through. Always validate at the controller/route level โ€” not just in the Mongoose schema โ€” because HTTP validation gives you much better error messages and prevents unnecessary database calls.
Tip: Create a reusable validate middleware helper that extracts express-validator errors and sends a 400 response. Add it as the last item in every route’s validation chain, before the controller function. This keeps the controller clean โ€” it can assume all input is already validated when it runs.
Warning: Always sanitise user input in addition to validating it. Validation checks that input meets your requirements (is it an email? is it a number?). Sanitisation transforms input to a safe form (.trim(), .escape(), .normalizeEmail()). Without sanitisation, a user could submit ” Jane ” (with spaces) that passes validation but stores inconsistently, or inject HTML into a title field that gets rendered in the browser.

Installation and Setup

cd server
npm install express-validator
// Three things you need from express-validator:
const { body, param, query, validationResult } = require('express-validator');

// body('fieldName')   โ†’ validate a field in req.body
// param('fieldName')  โ†’ validate a URL parameter in req.params
// query('fieldName')  โ†’ validate a query string in req.query
// validationResult()  โ†’ extract validation errors from the request

The validate Middleware Helper

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

// Add this as the last item in any validation chain
// It collects all express-validator errors and sends a 400 if any exist
const validate = (req, res, next) => {
  const errors = validationResult(req);

  if (!errors.isEmpty()) {
    // Format errors as field โ†’ message pairs for easy use in React forms
    const formatted = errors.array().map(err => ({
      field:   err.path,
      message: err.msg,
    }));

    return res.status(400).json({
      success: false,
      message: 'Validation failed',
      errors:  formatted,
    });
  }

  next();
};

module.exports = validate;

Post Validation Rules

// server/src/validators/postValidators.js
const { body } = require('express-validator');

const createPostRules = [
  body('title')
    .trim()
    .notEmpty()     .withMessage('Title is required')
    .isLength({ min: 3, max: 200 })
                    .withMessage('Title must be between 3 and 200 characters'),

  body('body')
    .trim()
    .notEmpty()     .withMessage('Post body is required')
    .isLength({ min: 10 })
                    .withMessage('Body must be at least 10 characters'),

  body('excerpt')
    .optional()
    .trim()
    .isLength({ max: 500 })
                    .withMessage('Excerpt cannot exceed 500 characters'),

  body('tags')
    .optional()
    .isArray()      .withMessage('Tags must be an array')
    .custom(arr => arr.every(tag => typeof tag === 'string'))
                    .withMessage('All tags must be strings'),

  body('published')
    .optional()
    .isBoolean()    .withMessage('Published must be true or false')
    .toBoolean(),   // converts string 'true'/'false' to boolean

  body('coverImage')
    .optional()
    .isURL()        .withMessage('Cover image must be a valid URL'),
];

const updatePostRules = [
  body('title')
    .optional()
    .trim()
    .isLength({ min: 3, max: 200 })
                    .withMessage('Title must be between 3 and 200 characters'),

  body('body')
    .optional()
    .trim()
    .isLength({ min: 10 })
                    .withMessage('Body must be at least 10 characters'),

  body('tags')
    .optional()
    .isArray()      .withMessage('Tags must be an array'),

  body('published')
    .optional()
    .isBoolean()    .withMessage('Published must be true or false')
    .toBoolean(),
];

module.exports = { createPostRules, updatePostRules };

Auth Validation Rules

// server/src/validators/authValidators.js
const { body } = require('express-validator');

const registerRules = [
  body('name')
    .trim()
    .notEmpty()     .withMessage('Name is required')
    .isLength({ min: 2, max: 50 })
                    .withMessage('Name must be between 2 and 50 characters'),

  body('email')
    .trim()
    .notEmpty()     .withMessage('Email is required')
    .isEmail()      .withMessage('Must be a valid email address')
    .normalizeEmail(), // lowercase, remove dots in Gmail addresses

  body('password')
    .notEmpty()     .withMessage('Password is required')
    .isLength({ min: 8 })
                    .withMessage('Password must be at least 8 characters')
    .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
                    .withMessage('Password must contain upper, lower and a number'),
];

const loginRules = [
  body('email')
    .trim()
    .notEmpty()     .withMessage('Email is required')
    .isEmail()      .withMessage('Must be a valid email address')
    .normalizeEmail(),

  body('password')
    .notEmpty()     .withMessage('Password is required'),
];

module.exports = { registerRules, loginRules };

Wiring Validation Into Routes

// server/src/routes/posts.js
const validate              = require('../middleware/validate');
const { createPostRules, updatePostRules } = require('../validators/postValidators');
const protect               = require('../middleware/auth');
const validateObjectId      = require('../middleware/validateObjectId');
const {
  getAllPosts, getPostById, createPost, updatePost, deletePost,
} = require('../controllers/postController');

router.route('/')
  .get(getAllPosts)
  .post(
    protect,          // 1. verify JWT
    createPostRules,  // 2. declare validation rules (array of middleware)
    validate,         // 3. check for errors โ€” send 400 if any
    createPost        // 4. only runs if validation passed
  );

router.route('/:id')
  .get(validateObjectId('id'), getPostById)
  .patch(
    protect,
    validateObjectId('id'),
    updatePostRules,
    validate,
    updatePost
  )
  .delete(protect, validateObjectId('id'), deletePost);

What the Error Response Looks Like

POST /api/posts
Body: { "title": "Hi", "published": "yes" }

Response: 400 Bad Request
{
  "success": false,
  "message": "Validation failed",
  "errors": [
    { "field": "title",     "message": "Title must be between 3 and 200 characters" },
    { "field": "body",      "message": "Post body is required" },
    { "field": "published", "message": "Published must be true or false" }
  ]
}

React usage โ€” display errors next to form fields:
  errors.find(e => e.field === 'title')?.message  โ†’ shown below the title input
  errors.find(e => e.field === 'body')?.message   โ†’ shown below the body textarea

Common Mistakes

Mistake 1 โ€” Passing createPostRules directly without spreading

โŒ Wrong โ€” passing the array itself as a single argument:

router.post('/', protect, createPostRules, validate, createPost);
// createPostRules is an array โ€” Express treats it as a single middleware
// This actually works in modern Express, but be explicit about it

โœ… Express does accept arrays of middleware โ€” but if in doubt, spread:

router.post('/', protect, ...createPostRules, validate, createPost); // explicit spread โœ“

Mistake 2 โ€” Validating in the controller instead of middleware

โŒ Wrong โ€” validation logic mixed into the business logic function:

const createPost = asyncHandler(async (req, res) => {
  if (!req.body.title) return res.status(400).json({ message: 'Title required' });
  if (req.body.title.length < 3) return res.status(400).json({ message: '...' });
  // 20 more lines of manual validation...
  const post = await Post.create(req.body); // finally โ€” the actual work
});

โœ… Correct โ€” keep controllers clean; validation lives in the route chain:

const createPost = asyncHandler(async (req, res) => {
  // Validation already done โ€” just do the work
  const { title, body, tags } = req.body;
  const post = await Post.create({ title, body, tags, author: req.user.id });
  res.status(201).json({ success: true, data: post });
});

Mistake 3 โ€” Not sanitising input before saving

โŒ Wrong โ€” storing raw user input including leading/trailing whitespace:

body('title').notEmpty() // validates but does not trim
// " Hello World " stored as-is โ€” inconsistent data

โœ… Correct โ€” always chain .trim() on string fields:

body('title').trim().notEmpty() // sanitises then validates โœ“

Quick Reference

Validator Usage
Required string body('title').trim().notEmpty().withMessage('...')
Length check .isLength({ min: 3, max: 200 }).withMessage('...')
Email body('email').isEmail().normalizeEmail()
Boolean body('published').isBoolean().toBoolean()
URL body('coverImage').optional().isURL()
Array body('tags').optional().isArray()
Integer query('page').optional().isInt({ min: 1 }).toInt()
Check errors validationResult(req).isEmpty()
Get errors validationResult(req).array()

🧠 Test Yourself

You add body('title').trim().notEmpty() to your createPost validation chain but call validate middleware before the validation rules in the route definition. What happens?