How to Build a REST API with Node.js and Express: A Complete Developer's Guide
Building REST APIs is a fundamental skill for modern web developers. Node.js combined with Express.js provides a powerful, lightweight foundation for creating robust APIs that can handle everything from simple CRUD operations to complex enterprise applications. This comprehensive guide will walk you through every step of building a professional REST API, from initial setup to production deployment.
Table of Contents
1. [Understanding REST APIs and Prerequisites](#understanding-rest) 2. [Project Setup and Environment Configuration](#project-setup) 3. [Setting Up Express.js](#express-setup) 4. [Creating API Routes](#creating-routes) 5. [Implementing CRUD Operations](#crud-operations) 6. [Working with Middleware](#middleware) 7. [Database Integration](#database-integration) 8. [Error Handling and Validation](#error-handling) 9. [Authentication and Security](#authentication) 10. [Testing Your API](#testing) 11. [Deployment Strategies](#deployment) 12. [Best Practices and Optimization](#best-practices)Understanding REST APIs and Prerequisites {#understanding-rest}
REST (Representational State Transfer) is an architectural style for designing networked applications. A REST API uses HTTP methods to perform operations on resources identified by URLs. Before diving into development, ensure you have:
- Node.js (version 14 or higher) installed - Basic understanding of JavaScript and HTTP protocols - A code editor (VS Code recommended) - Postman or similar API testing tool
REST Principles
- Stateless: Each request contains all necessary information - Client-Server Architecture: Clear separation of concerns - Cacheable: Responses should be cacheable when appropriate - Uniform Interface: Consistent resource identification and manipulationProject Setup and Environment Configuration {#project-setup}
Let's start by creating a new Node.js project and setting up the necessary dependencies.
Initialize Your Project
`bash
Create project directory
mkdir rest-api-tutorial cd rest-api-tutorialInitialize npm project
npm init -yInstall core dependencies
npm install express dotenv cors helmet morgan npm install -D nodemon concurrentlyInstall additional packages for database and validation
npm install mongoose joi bcryptjs jsonwebtoken`Project Structure
Create the following directory structure:
`
rest-api-tutorial/
├── src/
│ ├── controllers/
│ ├── middleware/
│ ├── models/
│ ├── routes/
│ ├── utils/
│ └── app.js
├── config/
│ └── database.js
├── tests/
├── .env
├── .gitignore
├── package.json
└── server.js
`
Environment Configuration
Create a .env file in your project root:
`env
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/rest-api-tutorial
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRE=7d
`
Update your package.json scripts:
`json
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"test": "jest",
"test:watch": "jest --watch"
}
}
`
Setting Up Express.js {#express-setup}
Now let's create the core Express application with essential middleware.
Basic Express Setup
Create src/app.js:
`javascript
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
require('dotenv').config();
const app = express();
// Security middleware app.use(helmet()); app.use(cors());
// Logging middleware app.use(morgan('combined'));
// Body parsing middleware app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true }));
// Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'success', message: 'API is running', timestamp: new Date().toISOString() }); });
// API routes will be added here app.use('/api/v1', require('./routes'));
// Global error handler app.use((err, req, res, next) => { console.error(err.stack); res.status(500).json({ status: 'error', message: 'Something went wrong!' }); });
// 404 handler app.use('*', (req, res) => { res.status(404).json({ status: 'error', message: 'Route not found' }); });
module.exports = app;
`
Create server.js:
`javascript
const app = require('./src/app');
const connectDB = require('./config/database');
const PORT = process.env.PORT || 3000;
// Connect to database connectDB();
// Start server
const server = app.listen(PORT, () => {
console.log(Server running in ${process.env.NODE_ENV} mode on port ${PORT});
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (err, promise) => {
console.log(Error: ${err.message});
server.close(() => {
process.exit(1);
});
});
`
Creating API Routes {#creating-routes}
Let's build a comprehensive routing system for our API.
Route Structure
Create src/routes/index.js:
`javascript
const express = require('express');
const router = express.Router();
// Import route modules const userRoutes = require('./users'); const postRoutes = require('./posts'); const authRoutes = require('./auth');
// Route definitions router.use('/auth', authRoutes); router.use('/users', userRoutes); router.use('/posts', postRoutes);
// API documentation route router.get('/', (req, res) => { res.json({ message: 'Welcome to the REST API', version: '1.0.0', endpoints: { auth: '/api/v1/auth', users: '/api/v1/users', posts: '/api/v1/posts' } }); });
module.exports = router;
`
User Routes
Create src/routes/users.js:
`javascript
const express = require('express');
const router = express.Router();
const {
getUsers,
getUser,
createUser,
updateUser,
deleteUser
} = require('../controllers/userController');
const { protect, authorize } = require('../middleware/auth');
const { validateUser } = require('../middleware/validation');
// Public routes router.get('/', getUsers); router.get('/:id', getUser);
// Protected routes router.post('/', protect, authorize('admin'), validateUser, createUser); router.put('/:id', protect, updateUser); router.delete('/:id', protect, authorize('admin'), deleteUser);
module.exports = router;
`
Post Routes
Create src/routes/posts.js:
`javascript
const express = require('express');
const router = express.Router();
const {
getPosts,
getPost,
createPost,
updatePost,
deletePost
} = require('../controllers/postController');
const { protect } = require('../middleware/auth');
const { validatePost } = require('../middleware/validation');
// Public routes router.get('/', getPosts); router.get('/:id', getPost);
// Protected routes router.post('/', protect, validatePost, createPost); router.put('/:id', protect, updatePost); router.delete('/:id', protect, deletePost);
module.exports = router;
`
Implementing CRUD Operations {#crud-operations}
Now let's implement the core CRUD (Create, Read, Update, Delete) operations with proper error handling and response formatting.
Database Models
First, create config/database.js:
`javascript
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
console.log(MongoDB Connected: ${conn.connection.host});
} catch (error) {
console.error('Database connection error:', error);
process.exit(1);
}
};
module.exports = connectDB;
`
Create src/models/User.js:
`javascript
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema({ name: { type: String, required: [true, 'Please provide a name'], maxlength: [50, 'Name cannot exceed 50 characters'] }, email: { type: String, required: [true, 'Please provide an email'], unique: true, match: [ /^\w+([.-]?\w+)@\w+([.-]?\w+)(\.\w{2,3})+$/, 'Please provide a valid email' ] }, password: { type: String, required: [true, 'Please provide a password'], minlength: 6, select: false }, role: { type: String, enum: ['user', 'admin'], default: 'user' }, isActive: { type: Boolean, default: true } }, { timestamps: true });
// Encrypt password before saving userSchema.pre('save', async function(next) { if (!this.isModified('password')) { next(); } const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); });
// Compare password method userSchema.methods.matchPassword = async function(enteredPassword) { return await bcrypt.compare(enteredPassword, this.password); };
module.exports = mongoose.model('User', userSchema);
`
Create src/models/Post.js:
`javascript
const mongoose = require('mongoose');
const postSchema = new mongoose.Schema({ title: { type: String, required: [true, 'Please provide a title'], maxlength: [100, 'Title cannot exceed 100 characters'] }, content: { type: String, required: [true, 'Please provide content'], maxlength: [2000, 'Content cannot exceed 2000 characters'] }, author: { type: mongoose.Schema.ObjectId, ref: 'User', required: true }, tags: [{ type: String, maxlength: [20, 'Tag cannot exceed 20 characters'] }], published: { type: Boolean, default: false }, publishedAt: { type: Date } }, { timestamps: true });
// Set publishedAt when published is set to true postSchema.pre('save', function(next) { if (this.published && !this.publishedAt) { this.publishedAt = new Date(); } next(); });
module.exports = mongoose.model('Post', postSchema);
`
User Controller
Create src/controllers/userController.js:
`javascript
const User = require('../models/User');
const asyncHandler = require('../utils/asyncHandler');
const ErrorResponse = require('../utils/errorResponse');
// @desc Get all users // @route GET /api/v1/users // @access Public exports.getUsers = asyncHandler(async (req, res, next) => { const page = parseInt(req.query.page, 10) || 1; const limit = parseInt(req.query.limit, 10) || 10; const startIndex = (page - 1) * limit;
const users = await User.find({ isActive: true }) .select('-password') .skip(startIndex) .limit(limit) .sort({ createdAt: -1 });
const total = await User.countDocuments({ isActive: true });
res.status(200).json({ success: true, count: users.length, total, pagination: { page, limit, pages: Math.ceil(total / limit) }, data: users }); });
// @desc Get single user // @route GET /api/v1/users/:id // @access Public exports.getUser = asyncHandler(async (req, res, next) => { const user = await User.findById(req.params.id).select('-password');
if (!user) { return next(new ErrorResponse('User not found', 404)); }
res.status(200).json({ success: true, data: user }); });
// @desc Create user // @route POST /api/v1/users // @access Private/Admin exports.createUser = asyncHandler(async (req, res, next) => { const user = await User.create(req.body);
res.status(201).json({ success: true, data: user }); });
// @desc Update user // @route PUT /api/v1/users/:id // @access Private exports.updateUser = asyncHandler(async (req, res, next) => { let user = await User.findById(req.params.id);
if (!user) { return next(new ErrorResponse('User not found', 404)); }
// Make sure user is updating their own profile or is admin if (user._id.toString() !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse('Not authorized to update this user', 403)); }
user = await User.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }).select('-password');
res.status(200).json({ success: true, data: user }); });
// @desc Delete user // @route DELETE /api/v1/users/:id // @access Private/Admin exports.deleteUser = asyncHandler(async (req, res, next) => { const user = await User.findById(req.params.id);
if (!user) { return next(new ErrorResponse('User not found', 404)); }
// Soft delete - set isActive to false user.isActive = false; await user.save();
res.status(200).json({
success: true,
message: 'User deleted successfully'
});
});
`
Post Controller
Create src/controllers/postController.js:
`javascript
const Post = require('../models/Post');
const asyncHandler = require('../utils/asyncHandler');
const ErrorResponse = require('../utils/errorResponse');
// @desc Get all posts // @route GET /api/v1/posts // @access Public exports.getPosts = asyncHandler(async (req, res, next) => { let query = Post.find();
// Filter by published posts for public access if (!req.user || req.user.role !== 'admin') { query = query.find({ published: true }); }
// Search functionality if (req.query.search) { query = query.find({ $or: [ { title: { $regex: req.query.search, $options: 'i' } }, { content: { $regex: req.query.search, $options: 'i' } } ] }); }
// Filter by tags if (req.query.tags) { const tags = req.query.tags.split(','); query = query.find({ tags: { $in: tags } }); }
// Pagination const page = parseInt(req.query.page, 10) || 1; const limit = parseInt(req.query.limit, 10) || 10; const startIndex = (page - 1) * limit;
query = query.skip(startIndex).limit(limit);
// Populate author query = query.populate({ path: 'author', select: 'name email' });
// Sort query = query.sort({ createdAt: -1 });
const posts = await query; const total = await Post.countDocuments(query.getFilter());
res.status(200).json({ success: true, count: posts.length, total, pagination: { page, limit, pages: Math.ceil(total / limit) }, data: posts }); });
// @desc Get single post // @route GET /api/v1/posts/:id // @access Public exports.getPost = asyncHandler(async (req, res, next) => { const post = await Post.findById(req.params.id).populate({ path: 'author', select: 'name email' });
if (!post) { return next(new ErrorResponse('Post not found', 404)); }
// Check if post is published or user is author/admin if (!post.published && (!req.user || (req.user.id !== post.author._id.toString() && req.user.role !== 'admin'))) { return next(new ErrorResponse('Post not found', 404)); }
res.status(200).json({ success: true, data: post }); });
// @desc Create post // @route POST /api/v1/posts // @access Private exports.createPost = asyncHandler(async (req, res, next) => { req.body.author = req.user.id;
const post = await Post.create(req.body);
res.status(201).json({ success: true, data: post }); });
// @desc Update post // @route PUT /api/v1/posts/:id // @access Private exports.updatePost = asyncHandler(async (req, res, next) => { let post = await Post.findById(req.params.id);
if (!post) { return next(new ErrorResponse('Post not found', 404)); }
// Make sure user is post owner or admin if (post.author.toString() !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse('Not authorized to update this post', 403)); }
post = await Post.findByIdAndUpdate(req.params.id, req.body, { new: true, runValidators: true }).populate({ path: 'author', select: 'name email' });
res.status(200).json({ success: true, data: post }); });
// @desc Delete post // @route DELETE /api/v1/posts/:id // @access Private exports.deletePost = asyncHandler(async (req, res, next) => { const post = await Post.findById(req.params.id);
if (!post) { return next(new ErrorResponse('Post not found', 404)); }
// Make sure user is post owner or admin if (post.author.toString() !== req.user.id && req.user.role !== 'admin') { return next(new ErrorResponse('Not authorized to delete this post', 403)); }
await post.remove();
res.status(200).json({
success: true,
message: 'Post deleted successfully'
});
});
`
Working with Middleware {#middleware}
Middleware functions are essential for handling cross-cutting concerns like authentication, validation, and error handling.
Utility Functions
Create src/utils/asyncHandler.js:
`javascript
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
`
Create src/utils/errorResponse.js:
`javascript
class ErrorResponse extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
Error.captureStackTrace(this, this.constructor); } }
module.exports = ErrorResponse;
`
Authentication Middleware
Create src/middleware/auth.js:
`javascript
const jwt = require('jsonwebtoken');
const asyncHandler = require('../utils/asyncHandler');
const ErrorResponse = require('../utils/errorResponse');
const User = require('../models/User');
// Protect routes exports.protect = asyncHandler(async (req, res, next) => { let token;
if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) { token = req.headers.authorization.split(' ')[1]; }
if (!token) { return next(new ErrorResponse('Not authorized to access this route', 401)); }
try { const decoded = jwt.verify(token, process.env.JWT_SECRET); req.user = await User.findById(decoded.id).select('-password'); if (!req.user) { return next(new ErrorResponse('No user found with this token', 401)); }
if (!req.user.isActive) { return next(new ErrorResponse('User account is deactivated', 401)); }
next(); } catch (err) { return next(new ErrorResponse('Not authorized to access this route', 401)); } });
// Grant access to specific roles
exports.authorize = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return next(new ErrorResponse(
User role ${req.user.role} is not authorized to access this route,
403
));
}
next();
};
};
`
Validation Middleware
Create src/middleware/validation.js:
`javascript
const Joi = require('joi');
const ErrorResponse = require('../utils/errorResponse');
const validateUser = (req, res, next) => { const schema = Joi.object({ name: Joi.string().min(2).max(50).required(), email: Joi.string().email().required(), password: Joi.string().min(6).required(), role: Joi.string().valid('user', 'admin').default('user') });
const { error } = schema.validate(req.body); if (error) { const message = error.details[0].message; return next(new ErrorResponse(message, 400)); } next(); };
const validatePost = (req, res, next) => { const schema = Joi.object({ title: Joi.string().min(5).max(100).required(), content: Joi.string().min(10).max(2000).required(), tags: Joi.array().items(Joi.string().max(20)), published: Joi.boolean().default(false) });
const { error } = schema.validate(req.body); if (error) { const message = error.details[0].message; return next(new ErrorResponse(message, 400)); } next(); };
const validateLogin = (req, res, next) => { const schema = Joi.object({ email: Joi.string().email().required(), password: Joi.string().required() });
const { error } = schema.validate(req.body); if (error) { const message = error.details[0].message; return next(new ErrorResponse(message, 400)); } next(); };
module.exports = {
validateUser,
validatePost,
validateLogin
};
`
Rate Limiting Middleware
Install and configure rate limiting:
`bash
npm install express-rate-limit
`
Create src/middleware/rateLimiter.js:
`javascript
const rateLimit = require('express-rate-limit');
const createRateLimiter = (windowMs, max, message) => { return rateLimit({ windowMs, max, message: { error: message }, standardHeaders: true, legacyHeaders: false, }); };
// Different rate limits for different endpoints const generalLimiter = createRateLimiter( 15 60 1000, // 15 minutes 100, // limit each IP to 100 requests per windowMs 'Too many requests from this IP, please try again later.' );
const authLimiter = createRateLimiter( 15 60 1000, // 15 minutes 5, // limit each IP to 5 requests per windowMs 'Too many authentication attempts, please try again later.' );
const createLimiter = createRateLimiter( 60 60 1000, // 1 hour 10, // limit each IP to 10 create requests per hour 'Too many items created from this IP, please try again later.' );
module.exports = {
generalLimiter,
authLimiter,
createLimiter
};
`
Database Integration {#database-integration}
Let's enhance our database integration with advanced features like indexing, aggregation, and optimized queries.
Advanced Database Configuration
Update config/database.js:
`javascript
const mongoose = require('mongoose');
const connectDB = async () => { try { const options = { useNewUrlParser: true, useUnifiedTopology: true, maxPoolSize: 10, // Maintain up to 10 socket connections serverSelectionTimeoutMS: 5000, // Keep trying to send operations for 5 seconds socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity bufferMaxEntries: 0 // Disable mongoose buffering };
const conn = await mongoose.connect(process.env.MONGODB_URI, options);
console.log(MongoDB Connected: ${conn.connection.host});
// Handle connection events mongoose.connection.on('error', (err) => { console.error('MongoDB connection error:', err); });
mongoose.connection.on('disconnected', () => { console.log('MongoDB disconnected'); });
// Graceful shutdown process.on('SIGINT', async () => { await mongoose.connection.close(); console.log('MongoDB connection closed through app termination'); process.exit(0); });
} catch (error) { console.error('Database connection error:', error); process.exit(1); } };
module.exports = connectDB;
`
Database Indexes and Optimization
Add indexes to your models for better query performance:
`javascript
// In User model
userSchema.index({ email: 1 }, { unique: true });
userSchema.index({ isActive: 1 });
userSchema.index({ createdAt: -1 });
// In Post model
postSchema.index({ author: 1 });
postSchema.index({ published: 1 });
postSchema.index({ tags: 1 });
postSchema.index({ title: 'text', content: 'text' }); // Text search
postSchema.index({ createdAt: -1 });
`
Database Seeding
Create src/utils/seeder.js for development data:
`javascript
const fs = require('fs');
const mongoose = require('mongoose');
const colors = require('colors');
const dotenv = require('dotenv');
// Load env vars dotenv.config();
// Load models const User = require('../models/User'); const Post = require('../models/Post');
// Connect to DB mongoose.connect(process.env.MONGODB_URI);
// Read JSON files
const users = JSON.parse(
fs.readFileSync(${__dirname}/../data/users.json, 'utf-8')
);
const posts = JSON.parse(
fs.readFileSync(${__dirname}/../data/posts.json, 'utf-8')
);
// Import into DB const importData = async () => { try { await User.create(users); await Post.create(posts); console.log('Data Imported...'.green.inverse); process.exit(); } catch (err) { console.error(err); } };
// Delete data const deleteData = async () => { try { await User.deleteMany(); await Post.deleteMany(); console.log('Data Destroyed...'.red.inverse); process.exit(); } catch (err) { console.error(err); } };
if (process.argv[2] === '-i') {
importData();
} else if (process.argv[2] === '-d') {
deleteData();
}
`
Error Handling and Validation {#error-handling}
Implement comprehensive error handling throughout your API.
Global Error Handler
Create src/middleware/errorHandler.js:
`javascript
const ErrorResponse = require('../utils/errorResponse');
const errorHandler = (err, req, res, next) => { let error = { ...err }; error.message = err.message;
// Log to console for dev console.log(err);
// Mongoose bad ObjectId if (err.name === 'CastError') { const message = 'Resource not found'; error = new ErrorResponse(message, 404); }
// Mongoose duplicate key if (err.code === 11000) { const message = 'Duplicate field value entered'; error = new ErrorResponse(message, 400); }
// Mongoose validation error if (err.name === 'ValidationError') { const message = Object.values(err.errors).map(val => val.message); error = new ErrorResponse(message, 400); }
// JWT errors if (err.name === 'JsonWebTokenError') { const message = 'Invalid token'; error = new ErrorResponse(message, 401); }
if (err.name === 'TokenExpiredError') { const message = 'Token expired'; error = new ErrorResponse(message, 401); }
res.status(error.statusCode || 500).json({ success: false, error: error.message || 'Server Error', ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); };
module.exports = errorHandler;
`
Request Validation
Create src/middleware/requestValidator.js:
`javascript
const { body, param, query, validationResult } = require('express-validator');
const ErrorResponse = require('../utils/errorResponse');
const handleValidationErrors = (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) { const errorMessages = errors.array().map(error => error.msg); return next(new ErrorResponse(errorMessages.join(', '), 400)); } next(); };
const userValidation = [ body('name') .trim() .isLength({ min: 2, max: 50 }) .withMessage('Name must be between 2 and 50 characters'), body('email') .isEmail() .normalizeEmail() .withMessage('Please provide a valid email'), body('password') .isLength({ min: 6 }) .withMessage('Password must be at least 6 characters long'), handleValidationErrors ];
const postValidation = [ body('title') .trim() .isLength({ min: 5, max: 100 }) .withMessage('Title must be between 5 and 100 characters'), body('content') .trim() .isLength({ min: 10, max: 2000 }) .withMessage('Content must be between 10 and 2000 characters'), body('tags') .optional() .isArray() .withMessage('Tags must be an array'), handleValidationErrors ];
const idValidation = [ param('id') .isMongoId() .withMessage('Invalid ID format'), handleValidationErrors ];
const paginationValidation = [ query('page') .optional() .isInt({ min: 1 }) .withMessage('Page must be a positive integer'), query('limit') .optional() .isInt({ min: 1, max: 100 }) .withMessage('Limit must be between 1 and 100'), handleValidationErrors ];
module.exports = {
userValidation,
postValidation,
idValidation,
paginationValidation
};
`
Authentication and Security {#authentication}
Implement robust authentication and security measures.
JWT Authentication
Create src/controllers/authController.js:
`javascript
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const asyncHandler = require('../utils/asyncHandler');
const ErrorResponse = require('../utils/errorResponse');
const sendEmail = require('../utils/sendEmail');
// Generate JWT Token const signToken = (id) => { return jwt.sign({ id }, process.env.JWT_SECRET, { expiresIn: process.env.JWT_EXPIRE, }); };
// Create token and send response const createSendToken = (user, statusCode, res) => { const token = signToken(user._id); const cookieOptions = { expires: new Date( Date.now() + process.env.JWT_COOKIE_EXPIRE 24 60 60 1000 ), httpOnly: true, secure: process.env.NODE_ENV === 'production', sameSite: 'strict' };
res.cookie('jwt', token, cookieOptions);
// Remove password from output user.password = undefined;
res.status(statusCode).json({ success: true, token, data: { user } }); };
// @desc Register user // @route POST /api/v1/auth/register // @access Public exports.register = asyncHandler(async (req, res, next) => { const { name, email, password, role } = req.body;
// Create user const user = await User.create({ name, email, password, role });
createSendToken(user, 201, res); });
// @desc Login user // @route POST /api/v1/auth/login // @access Public exports.login = asyncHandler(async (req, res, next) => { const { email, password } = req.body;
// Validate email & password if (!email || !password) { return next(new ErrorResponse('Please provide an email and password', 400)); }
// Check for user const user = await User.findOne({ email }).select('+password');
if (!user || !(await user.matchPassword(password))) { return next(new ErrorResponse('Invalid credentials', 401)); }
createSendToken(user, 200, res); });
// @desc Log user out / clear cookie // @route GET /api/v1/auth/logout // @access Public exports.logout = asyncHandler(async (req, res, next) => { res.cookie('jwt', 'none', { expires: new Date(Date.now() + 10 * 1000), httpOnly: true, });
res.status(200).json({ success: true, message: 'Logged out successfully' }); });
// @desc Get current logged in user // @route GET /api/v1/auth/me // @access Private exports.getMe = asyncHandler(async (req, res, next) => { const user = await User.findById(req.user.id);
res.status(200).json({ success: true, data: user }); });
// @desc Update user details // @route PUT /api/v1/auth/updatedetails // @access Private exports.updateDetails = asyncHandler(async (req, res, next) => { const fieldsToUpdate = { name: req.body.name, email: req.body.email };
const user = await User.findByIdAndUpdate(req.user.id, fieldsToUpdate, { new: true, runValidators: true });
res.status(200).json({ success: true, data: user }); });
// @desc Update password // @route PUT /api/v1/auth/updatepassword // @access Private exports.updatePassword = asyncHandler(async (req, res, next) => { const user = await User.findById(req.user.id).select('+password');
// Check current password if (!(await user.matchPassword(req.body.currentPassword))) { return next(new ErrorResponse('Password is incorrect', 401)); }
user.password = req.body.newPassword; await user.save();
createSendToken(user, 200, res);
});
`
Security Middleware
Create src/middleware/security.js:
`javascript
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const hpp = require('hpp');
const cors = require('cors');
const setupSecurity = (app) => { // Set security headers app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'"], imgSrc: ["'self'", "data:", "https:"], }, }, hsts: { maxAge: 31536000, includeSubDomains: true, preload: true } }));
// Rate limiting const limiter = rateLimit({ windowMs: 10 60 1000, // 10 mins max: 100, message: 'Too many requests from this IP, please try again later.' }); app.use('/api/', limiter);
// Body parser, reading data from body into req.body app.use(express.json({ limit: '10kb' }));
// Data sanitization against NoSQL query injection app.use(mongoSanitize());
// Data sanitization against XSS app.use(xss());
// Prevent parameter pollution app.use(hpp({ whitelist: ['sort', 'fields', 'page', 'limit'] }));
// Enable CORS app.use(cors({ origin: process.env.CORS_ORIGIN || '*', credentials: true })); };
module.exports = setupSecurity;
`
Testing Your API {#testing}
Implement comprehensive testing strategies for your API.
Unit Tests Setup
Install testing dependencies:
`bash
npm install -D jest supertest mongodb-memory-server
`
Create jest.config.js:
`javascript
module.exports = {
testEnvironment: 'node',
setupFilesAfterEnv: ['`
Create tests/setup.js:
`javascript
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
let mongod;
// Connect to the in-memory database before running tests beforeAll(async () => { mongod = await MongoMemoryServer.create(); const uri = mongod.getUri(); await mongoose.connect(uri); });
// Clear all test data after every test afterEach(async () => { const collections = mongoose.connection.collections; for (const key in collections) { const collection = collections[key]; await collection.deleteMany(); } });
// Remove and close the db and server after tests
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
await mongod.stop();
});
`
API Tests
Create tests/auth.test.js:
`javascript
const request = require('supertest');
const app = require('../src/app');
const User = require('../src/models/User');
describe('Authentication Endpoints', () => { describe('POST /api/v1/auth/register', () => { it('should register a new user', async () => { const userData = { name: 'Test User', email: 'test@example.com', password: 'password123' };
const res = await request(app) .post('/api/v1/auth/register') .send(userData) .expect(201);
expect(res.body.success).toBe(true); expect(res.body.token).toBeDefined(); expect(res.body.data.user.email).toBe(userData.email); expect(res.body.data.user.password).toBeUndefined(); });
it('should not register user with invalid email', async () => { const userData = { name: 'Test User', email: 'invalid-email', password: 'password123' };
const res = await request(app) .post('/api/v1/auth/register') .send(userData) .expect(400);
expect(res.body.success).toBe(false); }); });
describe('POST /api/v1/auth/login', () => { beforeEach(async () => { const user = new User({ name: 'Test User', email: 'test@example.com', password: 'password123' }); await user.save(); });
it('should login with valid credentials', async () => { const res = await request(app) .post('/api/v1/auth/login') .send({ email: 'test@example.com', password: 'password123' }) .expect(200);
expect(res.body.success).toBe(true); expect(res.body.token).toBeDefined(); });
it('should not login with invalid credentials', async () => { const res = await request(app) .post('/api/v1/auth/login') .send({ email: 'test@example.com', password: 'wrongpassword' }) .expect(401);
expect(res.body.success).toBe(false);
});
});
});
`
Create tests/posts.test.js:
`javascript
const request = require('supertest');
const app = require('../src/app');
const User = require('../src/models/User');
const Post = require('../src/models/Post');
describe('Posts Endpoints', () => { let authToken; let userId;
beforeEach(async () => { // Create and login user const user = new User({ name: 'Test User', email: 'test@example.com', password: 'password123' }); await user.save(); userId = user._id;
const loginRes = await request(app) .post('/api/v1/auth/login') .send({ email: 'test@example.com', password: 'password123' });
authToken = loginRes.body.token; });
describe('GET /api/v1/posts', () => { it('should get all published posts', async () => { // Create test posts await Post.create([ { title: 'Published Post', content: 'This is published content', author: userId, published: true }, { title: 'Draft Post', content: 'This is draft content', author: userId, published: false } ]);
const res = await request(app) .get('/api/v1/posts') .expect(200);
expect(res.body.success).toBe(true); expect(res.body.data).toHaveLength(1); expect(res.body.data[0].title).toBe('Published Post'); }); });
describe('POST /api/v1/posts', () => { it('should create a new post', async () => { const postData = { title: 'Test Post', content: 'This is test content for the post', tags: ['test', 'api'] };
const res = await request(app)
.post('/api/v1/posts')
.set('Authorization', Bearer ${authToken})
.send(postData)
.expect(201);
expect(res.body.success).toBe(true); expect(res.body.data.title).toBe(postData.title); expect(res.body.data.author).toBe(userId.toString()); });
it('should not create post without authentication', async () => { const postData = { title: 'Test Post', content: 'This is test content' };
const res = await request(app) .post('/api/v1/posts') .send(postData) .expect(401);
expect(res.body.success).toBe(false);
});
});
});
`
Deployment Strategies {#deployment}
Prepare your API for production deployment with proper configuration and optimization.
Environment Configuration
Create production environment files:
.env.production:
`env
NODE_ENV=production
PORT=3000
MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/production-db
JWT_SECRET=your-super-secure-production-jwt-secret
JWT_EXPIRE=7d
JWT_COOKIE_EXPIRE=7
Email configuration
SMTP_HOST=smtp.mailtrap.io SMTP_PORT=2525 SMTP_EMAIL=noreply@yourapi.com SMTP_PASSWORD=your-smtp-password FROM_EMAIL=noreply@yourapi.com FROM_NAME=Your APIAWS S3 (if using file uploads)
AWS_ACCESS_KEY_ID=your-aws-access-key AWS_SECRET_ACCESS_KEY=your-aws-secret-key AWS_BUCKET_NAME=your-s3-bucket`Docker Configuration
Create Dockerfile:
`dockerfile
Multi-stage build for production optimization
FROM node:18-alpine AS builderWORKDIR /app
Copy package files
COPY package*.json ./Install dependencies
RUN npm ci --only=production && npm cache clean --forceProduction stage
FROM node:18-alpine AS productionCreate app directory
WORKDIR /appCreate non-root user
RUN addgroup -g 1001 -S nodejs RUN adduser -S nodejs -u 1001Copy built dependencies
COPY --from=builder /app/node_modules ./node_modulesCopy application code
COPY --chown=nodejs:nodejs . .Switch to non-root user
USER nodejsExpose port
EXPOSE 3000Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD node healthcheck.jsStart the application
CMD ["node", "server.js"]`Create docker-compose.yml:
`yaml
version: '3.8'
services: api: build: . ports: - "3000:3000" environment: - NODE_ENV=production - MONGODB_URI=mongodb://mongo:27017/rest-api-production depends_on: - mongo - redis volumes: - ./logs:/app/logs restart: unless-stopped networks: - app-network
mongo: image: mongo:5.0 ports: - "27017:27017" volumes: - mongo-data:/data/db environment: - MONGO_INITDB_ROOT_USERNAME=admin - MONGO_INITDB_ROOT_PASSWORD=password restart: unless-stopped networks: - app-network
redis: image: redis:7-alpine ports: - "6379:6379" volumes: - redis-data:/data restart: unless-stopped networks: - app-network
nginx: image: nginx:alpine ports: - "80:80" - "443:443" volumes: - ./nginx.conf:/etc/nginx/nginx.conf - ./ssl:/etc/nginx/ssl depends_on: - api restart: unless-stopped networks: - app-network
volumes: mongo-data: redis-data:
networks:
app-network:
driver: bridge
`
Production Optimizations
Create src/utils/logger.js:
`javascript
const winston = require('winston');
const logger = winston.createLogger({ level: process.env.LOG_LEVEL || 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.errors({ stack: true }), winston.format.json() ), defaultMeta: { service: 'rest-api' }, transports: [ new winston.transports.File({ filename: 'logs/error.log', level: 'error' }), new winston.transports.File({ filename: 'logs/combined.log' }), ], });
if (process.env.NODE_ENV !== 'production') { logger.add(new winston.transports.Console({ format: winston.format.simple() })); }
module.exports = logger;
`
Monitoring and Health Checks
Create healthcheck.js:
`javascript
const http = require('http');
const options = { hostname: 'localhost', port: process.env.PORT || 3000, path: '/health', method: 'GET', timeout: 2000 };
const request = http.request(options, (res) => {
console.log(Health check status: ${res.statusCode});
if (res.statusCode === 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on('error', (err) => { console.error('Health check failed:', err.message); process.exit(1); });
request.on('timeout', () => { console.error('Health check timed out'); request.abort(); process.exit(1); });
request.end();
`
Best Practices and Optimization {#best-practices}
Performance Optimization
1. Database Query Optimization:
`javascript
// Use lean() for read-only queries
const posts = await Post.find().lean();
// Use select() to limit returned fields const users = await User.find().select('name email');
// Use aggregation for complex queries
const stats = await Post.aggregate([
{ $match: { published: true } },
{ $group: { _id: '$author', count: { $sum: 1 } } }
]);
`
2. Caching Strategy:
`javascript
const redis = require('redis');
const client = redis.createClient();
const cacheMiddleware = (duration = 300) => {
return async (req, res, next) => {
const key = req.originalUrl;
const cached = await client.get(key);
if (cached) {
return res.json(JSON.parse(cached));
}
res.sendResponse = res.json;
res.json = (body) => {
client.setex(key, duration, JSON.stringify(body));
res.sendResponse(body);
};
next();
};
};
`
3. API Documentation:
`bash
npm install swagger-jsdoc swagger-ui-express
`
Create API documentation with Swagger:
`javascript
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const options = { definition: { openapi: '3.0.0', info: { title: 'REST API Documentation', version: '1.0.0', description: 'A comprehensive REST API built with Node.js and Express' }, servers: [ { url: 'http://localhost:3000/api/v1', description: 'Development server' } ] }, apis: ['./src/routes/*.js'] };
const specs = swaggerJsdoc(options);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs));
`
Security Best Practices
1. Input Sanitization: Always validate and sanitize user input 2. Rate Limiting: Implement appropriate rate limits for different endpoints 3. HTTPS Only: Use HTTPS in production 4. JWT Security: Use strong secrets and appropriate expiration times 5. CORS Configuration: Configure CORS properly for your use case 6. Security Headers: Use helmet.js for security headers 7. Dependency Updates: Regularly update dependencies for security patches
Code Organization
1. Separation of Concerns: Keep controllers, services, and models separate 2. Error Handling: Implement consistent error handling throughout the application 3. Logging: Use proper logging for debugging and monitoring 4. Testing: Maintain good test coverage 5. Documentation: Document your API endpoints and business logic 6. Version Control: Use semantic versioning for your API
Conclusion
Building a REST API with Node.js and Express requires careful planning and implementation of various components. This guide has covered:
- Project Setup: Proper initialization and dependency management - Express Configuration: Setting up the server with essential middleware - Routing: Creating organized and RESTful routes - CRUD Operations: Implementing complete data operations - Middleware: Authentication, validation, and error handling - Database Integration: MongoDB integration with Mongoose - Security: Implementing security best practices - Testing: Comprehensive testing strategies - Deployment: Production-ready deployment configurations - Optimization: Performance and security optimizations
Remember that building APIs is an iterative process. Start with the basics, then gradually add more advanced features as your application grows. Always prioritize security, performance, and maintainability in your implementation.
The code examples provided in this guide serve as a solid foundation for building production-ready REST APIs. Adapt and extend them based on your specific requirements, and always keep learning about new best practices and technologies in the rapidly evolving Node.js ecosystem.