Skip to main content

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

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

javascript
// 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:

  1. Group routes by resource
  2. Use descriptive route names
  3. Follow RESTful naming conventions
  4. Version your API

Example: Route Organization

javascript
// 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:

javascript
// 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:

  1. Use a centralized error handler
  2. Return appropriate HTTP status codes
  3. Include meaningful error messages
  4. Avoid exposing sensitive information

Example: Error Handling

javascript
// 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

javascript
// 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:

  1. Use compression
  2. Implement caching
  3. Pagination for large data sets
  4. Select only needed fields from database

Example: Performance Optimizations

javascript
// 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

javascript
// 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:

bash
npm install swagger-jsdoc swagger-ui-express

Then, set up Swagger in your Express app:

javascript
// 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:

javascript
// 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

javascript
// 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:

  1. Use HTTPS: Always use HTTPS in production.
  2. Validate Input: Sanitize and validate all user input.
  3. Rate Limiting: Prevent brute force and DoS attacks.
  4. Secure Headers: Use Helmet to set secure HTTP headers.
  5. CORS: Configure proper Cross-Origin Resource Sharing.
  6. Authentication: Implement strong authentication methods.
  7. Authorization: Enforce proper access controls.
  8. Data Validation: Validate request bodies against schemas.
  9. Environment Variables: Store secrets in environment variables.
  10. 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

Exercises

  1. Basic Structure: Create a new Express API project following the recommended folder structure.
  2. Validation: Implement request validation for a user registration endpoint.
  3. Error Handling: Add centralized error handling to your API.
  4. Authentication: Implement JWT authentication for protected routes.
  5. Documentation: Document your API endpoints using OpenAPI/Swagger.
  6. 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! :)