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
- Express.js documentation
- express-validator documentation
- RESTful API design guide (Google)
- OpenAPI 3.0 specification
- HTTP status codes reference (MDN)
Comments