RESTful API Design: Best Practices for Clean, Scalable, and Debuggable APIs
A "decent" API not only returns correct data but also is:
- Predictable for clients.
- Easy to debug when errors occur.
- Scalable as business logic becomes more complex.
This article packages best practices + patterns I often use when designing RESTful APIs for backend services.
Resource Naming Conventions
Use Nouns, not Verbs
# ❌ Avoid
GET /api/getUsers
POST /api/createUser
PUT /api/updateUser/123
DELETE /api/deleteUser/123
# ✅ Better
GET /api/users # Get list of users
POST /api/users # Create new user
PUT /api/users/123 # Update user
DELETE /api/users/123 # Delete user
Hierarchical Resources
# User's posts
GET /api/users/123/posts
POST /api/users/123/posts
# Specific post
GET /api/users/123/posts/456
PUT /api/users/123/posts/456
DELETE /api/users/123/posts/456
# Post's comments
GET /api/posts/456/comments
POST /api/posts/456/comments
Collection vs Resource
# Collections (plural)
GET /api/users # User list
GET /api/posts # Post list
# Resources (singular in URL concept, but endpoint often plural)
GET /api/users/123 # A specific user
GET /api/posts/456 # A specific post
HTTP Methods and Status Codes
Proper HTTP Methods
// GET - Retrieve data
app.get('/api/users', async (req, res) => {
const users = await User.find();
res.json({
data: users,
total: users.length
});
});
// POST - Create new resource
app.post('/api/users', async (req, res) => {
try {
const user = await User.create(req.body);
res.status(201).json({
message: 'User created successfully',
data: user
});
} catch (error) {
res.status(400).json({
error: 'Validation failed',
details: error.message
});
}
});
// PUT - Update entire resource
app.put('/api/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
res.json({
message: 'User updated successfully',
data: user
});
} catch (error) {
res.status(400).json({
error: 'Update failed',
details: error.message
});
}
});
// PATCH - Partial update
app.patch('/api/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndUpdate(
req.params.id,
{ $set: req.body },
{ new: true, runValidators: true }
);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
res.json({
message: 'User updated successfully',
data: user
});
} catch (error) {
res.status(400).json({
error: 'Update failed',
details: error.message
});
}
});
// DELETE - Remove resource
app.delete('/api/users/:id', async (req, res) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
res.status(204).send(); // No content
} catch (error) {
res.status(500).json({
error: 'Delete failed',
details: error.message
});
}
});
Comprehensive Status Codes
const StatusCodes = {
// Success
OK: 200, // GET, PUT, PATCH success
CREATED: 201, // POST success
NO_CONTENT: 204, // DELETE success
// Client Errors
BAD_REQUEST: 400, // Invalid request data
UNAUTHORIZED: 401, // Authentication required
FORBIDDEN: 403, // Access denied
NOT_FOUND: 404, // Resource not found
METHOD_NOT_ALLOWED: 405, // HTTP method not supported
CONFLICT: 409, // Resource conflict
UNPROCESSABLE_ENTITY: 422, // Validation errors
TOO_MANY_REQUESTS: 429, // Rate limiting
// Server Errors
INTERNAL_SERVER_ERROR: 500,
NOT_IMPLEMENTED: 501,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503
};
Error Handling
Consistent Error Response Format
class APIError extends Error {
constructor(message, statusCode, code = null, details = null) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.details = details;
}
}
// Error handler middleware
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
// Mongoose validation error
if (err.name === 'ValidationError') {
const message = Object.values(err.errors).map(val => val.message);
error = new APIError('Validation Error', 400, 'VALIDATION_ERROR', message);
}
// Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
const message = `${field} already exists`;
error = new APIError(message, 409, 'DUPLICATE_RESOURCE');
}
// Mongoose ObjectId error
if (err.name === 'CastError') {
const message = 'Resource not found';
error = new APIError(message, 404, 'RESOURCE_NOT_FOUND');
}
res.status(error.statusCode || 500).json({
success: false,
error: {
message: error.message || 'Server Error',
code: error.code,
details: error.details,
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
};
Validation Errors
const { body, validationResult } = require('express-validator');
// Validation rules
const userValidationRules = () => {
return [
body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail(),
body('password')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Password must contain uppercase, lowercase and number'),
body('name')
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Name must be between 2 and 50 characters')
];
};
// Validation middleware
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
success: false,
error: {
message: 'Validation failed',
code: 'VALIDATION_ERROR',
details: errors.array().map(err => ({
field: err.param,
message: err.msg,
value: err.value
}))
}
});
}
next();
};
// Usage
app.post('/api/users', userValidationRules(), validate, createUser);
Pagination
Cursor-based Pagination
// Better for large datasets and real-time data
app.get('/api/posts', async (req, res) => {
const { cursor, limit = 20, sort = 'created_at' } = req.query;
let query = {};
if (cursor) {
// Decode cursor (base64 encoded timestamp)
const decodedCursor = Buffer.from(cursor, 'base64').toString('ascii');
query.created_at = { $lt: new Date(decodedCursor) };
}
const posts = await Post.find(query)
.sort({ [sort]: -1 })
.limit(parseInt(limit) + 1); // +1 to check if there's next page
const hasNextPage = posts.length > limit;
if (hasNextPage) posts.pop(); // Remove extra item
const nextCursor = hasNextPage
? Buffer.from(posts[posts.length - 1].created_at.toISOString()).toString('base64')
: null;
res.json({
data: posts,
pagination: {
hasNextPage,
nextCursor,
limit: parseInt(limit)
}
});
});
Offset-based Pagination
// Simpler but less efficient for large datasets
app.get('/api/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const skip = (page - 1) * limit;
const [users, total] = await Promise.all([
User.find()
.skip(skip)
.limit(limit)
.sort({ created_at: -1 }),
User.countDocuments()
]);
const totalPages = Math.ceil(total / limit);
res.json({
data: users,
pagination: {
page,
limit,
total,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1
}
});
});
Filtering, Sorting, and Searching
Advanced Query Parameters
app.get('/api/products', async (req, res) => {
const {
// Filtering
category,
minPrice,
maxPrice,
inStock,
tags,
// Sorting
sort = 'created_at',
order = 'desc',
// Searching
search,
// Pagination
page = 1,
limit = 20,
// Field selection
fields
} = req.query;
// Build filter object
let filter = {};
if (category) filter.category = category;
if (minPrice || maxPrice) {
filter.price = {};
if (minPrice) filter.price.$gte = parseFloat(minPrice);
if (maxPrice) filter.price.$lte = parseFloat(maxPrice);
}
if (inStock !== undefined) filter.inStock = inStock === 'true';
if (tags) filter.tags = { $in: tags.split(',') };
// Search
if (search) {
filter.$or = [
{ name: { $regex: search, $options: 'i' } },
{ description: { $regex: search, $options: 'i' } }
];
}
// Build sort object
const sortObj = {};
sortObj[sort] = order === 'desc' ? -1 : 1;
// Build query
let query = Product.find(filter).sort(sortObj);
// Field selection
if (fields) {
const selectedFields = fields.split(',').join(' ');
query = query.select(selectedFields);
}
// Pagination
const skip = (page - 1) * limit;
query = query.skip(skip).limit(parseInt(limit));
const [products, total] = await Promise.all([
query.exec(),
Product.countDocuments(filter)
]);
res.json({
data: products,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / limit)
},
filters: {
category,
priceRange: { min: minPrice, max: maxPrice },
inStock,
tags: tags?.split(','),
search
}
});
});
Authentication and Authorization
JWT Authentication
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
// Login endpoint
app.post('/api/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
// Find user
const user = await User.findOne({ email }).select('+password');
if (!user) {
return res.status(401).json({
success: false,
error: {
message: 'Invalid credentials',
code: 'INVALID_CREDENTIALS'
}
});
}
// Check password
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
error: {
message: 'Invalid credentials',
code: 'INVALID_CREDENTIALS'
}
});
}
// Generate tokens
const accessToken = jwt.sign(
{ userId: user._id, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '15m' }
);
const refreshToken = jwt.sign(
{ userId: user._id },
process.env.JWT_REFRESH_SECRET,
{ expiresIn: '7d' }
);
// Save refresh token
user.refreshToken = refreshToken;
await user.save();
res.json({
success: true,
data: {
user: {
id: user._id,
email: user.email,
name: user.name
},
tokens: {
accessToken,
refreshToken
}
}
});
} catch (error) {
res.status(500).json({
success: false,
error: {
message: 'Login failed',
details: error.message
}
});
}
});
// Auth middleware
const authenticate = async (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
error: {
message: 'Access token required',
code: 'TOKEN_REQUIRED'
}
});
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.userId);
if (!user) {
return res.status(401).json({
success: false,
error: {
message: 'Invalid token',
code: 'INVALID_TOKEN'
}
});
}
req.user = user;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
error: {
message: 'Token expired',
code: 'TOKEN_EXPIRED'
}
});
}
res.status(401).json({
success: false,
error: {
message: 'Invalid token',
code: 'INVALID_TOKEN'
}
});
}
});
Role-based Authorization
const authorize = (...roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
error: {
message: 'Authentication required',
code: 'AUTH_REQUIRED'
}
});
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({
success: false,
error: {
message: 'Insufficient permissions',
code: 'INSUFFICIENT_PERMISSIONS'
}
});
}
next();
};
};
// Usage
app.get('/api/admin/users', authenticate, authorize('admin'), getUsers);
app.delete('/api/posts/:id', authenticate, authorize('admin', 'moderator'), deletePost);
Rate Limiting
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
// General rate limiting
const generalLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:general:'
}),
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: {
success: false,
error: {
message: 'Too many requests, please try again later',
code: 'RATE_LIMIT_EXCEEDED'
}
}
});
// Strict rate limiting for auth endpoints
const authLimiter = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:auth:'
}),
windowMs: 15 * 60 * 1000,
max: 5, // limit each IP to 5 requests per windowMs
skipSuccessfulRequests: true
});
app.use('/api/', generalLimiter);
app.use('/api/auth/', authLimiter);
API Versioning
URL Versioning
// v1 routes
app.use('/api/v1/users', v1UserRoutes);
app.use('/api/v1/posts', v1PostRoutes);
// v2 routes
app.use('/api/v2/users', v2UserRoutes);
app.use('/api/v2/posts', v2PostRoutes);
// Default to latest version
app.use('/api/users', v2UserRoutes);
app.use('/api/posts', v2PostRoutes);
Header Versioning
const versionMiddleware = (req, res, next) => {
const version = req.headers['api-version'] || 'v2';
req.apiVersion = version;
next();
};
app.use('/api/', versionMiddleware);
app.get('/api/users', (req, res) => {
if (req.apiVersion === 'v1') {
// Handle v1 logic
return handleV1Users(req, res);
} else {
// Handle v2 logic
return handleV2Users(req, res);
}
});
Documentation
OpenAPI/Swagger
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const options = {
definition: {
openapi: '3.0.0',
info: {
title: 'My API',
version: '1.0.0',
description: 'A sample API'
},
servers: [
{
url: 'http://localhost:3000/api',
description: 'Development server'
}
]
},
apis: ['./routes/*.js'] // paths to files containing OpenAPI definitions
};
const specs = swaggerJsdoc(options);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
/**
* @swagger
* /users:
* get:
* summary: Get all users
* tags: [Users]
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* description: Page number
* responses:
* 200:
* description: List of users
* content:
* application/json:
* schema:
* type: object
* properties:
* data:
* type: array
* items:
* $ref: '#/components/schemas/User'
* */
Conclusion
Good RESTful API design requires:
- Consistent naming conventions and resource structure
- Proper HTTP methods and status codes
- Comprehensive error handling with clear error messages
- Efficient pagination strategies
- Flexible filtering and searching
- Robust authentication and authorization
- Rate limiting to protect API
- Versioning strategy for backward compatibility
- Good documentation for developers
These patterns will help your API be easy to use, maintain, and scale in the future.
Tags: API, REST, Backend, Node.js, Express, Best Practices, Web Development


