Express API Best Practices
When developing REST APIs with Express.js, following established best practices helps ensure your API is secure, maintainable, and performs well. This guide will walk you through essential practices to level up your Express API development skills.
Introduction
Building an API isn't just about making it work—it's about making it work well. This means creating endpoints that are intuitive, secure, performant, and easy to maintain. Whether you're building your first Express API or looking to improve existing ones, these best practices will help you create professional-quality APIs.
Table of Contents
- Project Structure
- Route Organization
- Request Validation
- Error Handling
- Authentication & Authorization
- Performance Optimization
- Testing
- Documentation
- Security Best Practices
Project Structure
A well-organized project structure improves maintainability and collaboration. Here's a recommended structure for Express APIs:
project-root/
├── config/ # Configuration files
├── controllers/ # Route handlers
├── middlewares/ # Custom middleware
├── models/ # Data models
├── routes/ # Route definitions
├── services/ # Business logic
├── utils/ # Utility functions
├── tests/ # Test files
├── app.js # Express app setup
└── server.js # Server entry point
This separation of concerns keeps your codebase organized and makes it easier to navigate as your project grows.
Example: Basic Express App Structure
// server.js - Entry point
const app = require('./app');
const config = require('./config');
const PORT = config.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// app.js - Express app setup
const express = require('express');
const routes = require('./routes');
const errorHandler = require('./middlewares/errorHandler');
const app = express();
app.use(express.json());
app.use('/api', routes);
app.use(errorHandler);
module.exports = app;
Route Organization
Organizing routes by resource helps keep your API intuitive and maintainable.
Best Practices:
- Group routes by resource
- Use descriptive route names
- Follow RESTful naming conventions
- Version your API
Example: Route Organization
// routes/index.js
const express = require('express');
const userRoutes = require('./userRoutes');
const productRoutes = require('./productRoutes');
const router = express.Router();
router.use('/users', userRoutes);
router.use('/products', productRoutes);
module.exports = router;
// routes/userRoutes.js
const express = require('express');
const userController = require('../controllers/userController');
const { authenticate } = require('../middlewares/auth');
const router = express.Router();
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/', userController.createUser);
router.put('/:id', authenticate, userController.updateUser);
router.delete('/:id', authenticate, userController.deleteUser);
module.exports = router;
Request Validation
Validating incoming requests prevents bad data from entering your application and provides clear feedback to API consumers.
Example using express-validator:
// middlewares/validate.js
const { validationResult } = require('express-validator');
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
};
module.exports = validate;
// controllers/userController.js
const { body } = require('express-validator');
const validate = require('../middlewares/validate');
// Validation rules
const createUserValidation = [
body('email').isEmail().withMessage('Enter a valid email'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
body('name').not().isEmpty().withMessage('Name is required'),
validate
];
// Controller with validation
exports.createUser = [
createUserValidation,
async (req, res, next) => {
try {
// Your controller logic here
const user = await userService.create(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
}
];
Error Handling
Proper error handling is crucial for API reliability and providing useful feedback to clients.
Best Practices:
- Use a centralized error handler
- Return appropriate HTTP status codes
- Include meaningful error messages
- Avoid exposing sensitive information
Example: Error Handling
// utils/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// middlewares/errorHandler.js
const AppError = require('../utils/AppError');
module.exports = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
err.status = err.status || 'error';
// Development error response (more details)
if (process.env.NODE_ENV === 'development') {
return res.status(err.statusCode).json({
status: err.status,
error: err,
message: err.message,
stack: err.stack
});
}
// Production error response (limited details)
if (err.isOperational) {
return res.status(err.statusCode).json({
status: err.status,
message: err.message
});
}
// For unknown errors in production, send generic message
console.error('ERROR 💥', err);
return res.status(500).json({
status: 'error',
message: 'Something went wrong'
});
};
// Example usage in a controller
const AppError = require('../utils/AppError');
exports.getUserById = async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) {
return next(new AppError('User not found', 404));
}
res.status(200).json({
status: 'success',
data: { user }
});
} catch (error) {
next(error);
}
};
Authentication & Authorization
Protecting your API endpoints is essential for security.
Example: JWT Authentication
// middlewares/auth.js
const jwt = require('jsonwebtoken');
const AppError = require('../utils/AppError');
const User = require('../models/User');
exports.authenticate = async (req, res, next) => {
try {
// 1) Get token from header
const authHeader = req.headers.authorization;
let token;
if (authHeader && authHeader.startsWith('Bearer')) {
token = authHeader.split(' ')[1];
}
if (!token) {
return next(new AppError('You are not logged in', 401));
}
// 2) Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// 3) Check if user still exists
const user = await User.findById(decoded.id);
if (!user) {
return next(new AppError('The user no longer exists', 401));
}
// 4) Check if user changed password after token was issued
if (user.changedPasswordAfter(decoded.iat)) {
return next(new AppError('User recently changed password. Please log in again', 401));
}
// Grant access to protected route
req.user = user;
next();
} catch (error) {
next(new AppError('Authentication failed', 401));
}
};
// Role-based authorization
exports.restrictTo = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return next(new AppError('You do not have permission to perform this action', 403));
}
next();
};
};
Performance Optimization
Optimizing your API's performance is important for a good user experience.
Best Practices:
- Use compression
- Implement caching
- Pagination for large data sets
- Select only needed fields from database
Example: Performance Optimizations
// app.js
const express = require('express');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const app = express();
// Compress all responses
app.use(compression());
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later'
});
app.use('/api', limiter);
// Example of pagination in a controller
exports.getAllUsers = async (req, res, next) => {
try {
// Pagination
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
// Field selection
let fields = '';
if (req.query.fields) {
fields = req.query.fields.split(',').join(' ');
}
const users = await User.find()
.select(fields)
.skip(skip)
.limit(limit);
res.status(200).json({
status: 'success',
results: users.length,
data: { users }
});
} catch (error) {
next(error);
}
};
Testing
Testing is vital for ensuring API reliability and catching issues before deployment.
Example: API Testing with Jest and Supertest
// tests/user.test.js
const request = require('supertest');
const app = require('../app');
const User = require('../models/User');
const mongoose = require('mongoose');
// Setup and teardown
beforeAll(async () => {
await mongoose.connect(process.env.TEST_DB_URI);
});
afterAll(async () => {
await mongoose.connection.close();
});
beforeEach(async () => {
await User.deleteMany({});
});
// Test suite
describe('User API', () => {
// Test case
test('should create a new user', async () => {
const userData = {
name: 'Test User',
email: '[email protected]',
password: 'password123'
};
const response = await request(app)
.post('/api/users')
.send(userData)
.expect(201);
expect(response.body).toHaveProperty('_id');
expect(response.body.name).toBe(userData.name);
expect(response.body.email).toBe(userData.email);
// Password should not be returned
expect(response.body).not.toHaveProperty('password');
// Verify user was saved to database
const user = await User.findById(response.body._id);
expect(user).not.toBeNull();
});
});
Documentation
Well-documented APIs are easier to understand and use.
Example: API Documentation with Swagger/OpenAPI
First, install the necessary package:
npm install swagger-jsdoc swagger-ui-express
Then, set up Swagger in your Express app:
// app.js
const swaggerJsDoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
// Swagger configuration
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'Express API',
version: '1.0.0',
description: 'A simple Express API'
},
servers: [
{
url: 'http://localhost:3000/api',
description: 'Development server'
}
]
},
apis: ['./routes/*.js']
};
const swaggerDocs = swaggerJsDoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerDocs));
Documenting routes with JSDoc comments:
// routes/userRoutes.js
/**
* @swagger
* /users:
* get:
* summary: Returns a list of users
* description: Retrieves a list of all users
* responses:
* 200:
* description: A list of users
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* id:
* type: string
* example: 5f8d0d55b54764421b71814d
* name:
* type: string
* example: John Doe
* email:
* type: string
* example: [email protected]
*/
router.get('/', userController.getAllUsers);
Security Best Practices
Implementing security best practices helps protect your API and user data.
Example: Security Middleware Setup
// app.js
const express = require('express');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const cors = require('cors');
const app = express();
// Set security HTTP headers
app.use(helmet());
// Sanitize data against NoSQL query injection
app.use(mongoSanitize());
// Sanitize data against XSS
app.use(xss());
// Prevent parameter pollution
app.use(hpp({
whitelist: ['name', 'email', 'role'] // parameters that can be duplicated
}));
// Enable CORS
app.use(cors());
// Handle preflight requests
app.options('*', cors());
Security Checklist:
- Use HTTPS: Always use HTTPS in production.
- Validate Input: Sanitize and validate all user input.
- Rate Limiting: Prevent brute force and DoS attacks.
- Secure Headers: Use Helmet to set secure HTTP headers.
- CORS: Configure proper Cross-Origin Resource Sharing.
- Authentication: Implement strong authentication methods.
- Authorization: Enforce proper access controls.
- Data Validation: Validate request bodies against schemas.
- Environment Variables: Store secrets in environment variables.
- Dependency Management: Keep dependencies updated and scan for vulnerabilities.
Summary
Building an Express API following these best practices will help you create robust, secure, and maintainable applications. Remember:
- Organize your code with a clear structure
- Validate and sanitize all inputs
- Implement comprehensive error handling
- Secure your API with authentication and authorization
- Optimize performance through caching and pagination
- Test thoroughly
- Document clearly
- Follow security best practices
By adhering to these guidelines, you'll be well on your way to developing professional-quality APIs that meet industry standards.
Additional Resources
- Express.js Official Documentation
- OWASP API Security Top 10
- RESTful API Design Best Practices
- Node.js Security Cheat Sheet
Exercises
- Basic Structure: Create a new Express API project following the recommended folder structure.
- Validation: Implement request validation for a user registration endpoint.
- Error Handling: Add centralized error handling to your API.
- Authentication: Implement JWT authentication for protected routes.
- Documentation: Document your API endpoints using OpenAPI/Swagger.
- Security: Add security middleware (helmet, rate limiting, etc.) to your Express app.
Working through these exercises will help reinforce your understanding of Express API best practices and give you hands-on experience implementing them.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)