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 |
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.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('...') |
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() |