Node.js
CRUD & Validation – Express Router, Controllers & Zod
Build a clean CRUD API with Express Router, a dedicated controller, input validation via Zod or express-validator, and proper async error handling.
Install: Add
zod for schema-based validation and express-validator for inline route validation chains.terminal
BASH
npm install zod express-validator
Zod Validation Schema: Define schemas once and reuse them. Use a middleware wrapper to easily validate incoming request bodies.
src/validators/userValidator.js
JS
const { z } = require('zod');
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(6),
role: z.enum(['user', 'admin']).default('user'),
});
const updateUserSchema = z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional(),
role: z.enum(['user', 'admin']).optional(),
}).refine(data => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
});
// Reusable middleware factory
const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
const messages = result.error.errors.map((e) => {
const path = e.path.join('.');
return path ? `${path}: ${e.message}` : e.message;
});
return res.status(422).json({ errors: messages });
}
req.body = result.data; // use sanitized/defaulted value
next();
};
module.exports = { createUserSchema, updateUserSchema, validate };
express-validator: Chain validators directly in route definitions. Call
validationResult(req) inside the controller to check for errors.src/routes/userRoutes.js (express-validator)
JS
const { body, param, validationResult } = require('express-validator');
// Re-usable helper to check results
const checkValidation = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({ errors: errors.array() });
}
next();
};
const createRules = [
body('name').trim().notEmpty().withMessage('Name is required'),
body('email').isEmail().normalizeEmail().withMessage('Valid email required'),
body('password').isLength({ min: 6 }).withMessage('Min 6 characters'),
checkValidation,
];
const idRule = [
param('id').isInt({ min: 1 }).withMessage('ID must be a positive integer'),
checkValidation,
];
module.exports = { createRules, idRule };
Controller Pattern: Keep controllers thin — delegate DB logic to a service/model layer. Wrap every async handler to forward errors to Express's global error handler.
src/controllers/userController.js
JS
const userService = require('../services/userService');
// Wrap async handlers to auto-catch rejections
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
const getAll = asyncHandler(async (req, res) => {
const { page = 1, limit = 10 } = req.query;
const result = await userService.paginate(Number(page), Number(limit));
res.json(result);
});
const getOne = asyncHandler(async (req, res) => {
const user = await userService.getById(req.params.id);
if (!user) return res.status(404).json({ message: 'User not found' });
res.json(user);
});
const create = asyncHandler(async (req, res) => {
const user = await userService.create(req.body);
res.status(201).json(user);
});
const update = asyncHandler(async (req, res) => {
const user = await userService.update(req.params.id, req.body);
if (!user) return res.status(404).json({ message: 'User not found' });
res.json(user);
});
const remove = asyncHandler(async (req, res) => {
const deleted = await userService.remove(req.params.id);
if (!deleted) return res.status(404).json({ message: 'User not found' });
res.status(204).send();
});
module.exports = { getAll, getOne, create, update, remove };
Full Router File: Wire routes, middleware, and validation together. The
protect middleware guards write operations.src/routes/userRoutes.js
JS
const express = require('express');
const router = express.Router();
const ctrl = require('../controllers/userController');
const { protect } = require('../middleware/authMiddleware');
const { createRules, idRule } = require('./userRules');
const { validate, createUserSchema } = require('../validators/userValidator');
// GET /api/users – list (paginated)
router.get('/', protect, ctrl.getAll);
// GET /api/users/:id – single
router.get('/:id', protect, idRule, ctrl.getOne);
// POST /api/users – create
router.post('/',
validate(createUserSchema), // Zod validation
ctrl.create
);
// PUT /api/users/:id – update
router.put('/:id',
protect,
idRule,
ctrl.update
);
// DELETE /api/users/:id – delete
router.delete('/:id', protect, idRule, ctrl.remove);
module.exports = router;
Global Error Handler: A four-argument Express middleware catches all forwarded errors. Register it after all routes in
server.js.src/middleware/errorHandler.js
JS
const errorHandler = (err, req, res, next) => {
console.error(`[${new Date().toISOString()}] ${err.stack}`);
// Mongoose validation error
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map((e) => e.message);
return res.status(422).json({ errors });
}
// Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
return res.status(409).json({ message: `${field} already exists` });
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
return res.status(403).json({ message: 'Invalid token' });
}
res.status(err.status || 500).json({
message: err.message || 'Internal Server Error',
});
};
module.exports = errorHandler;