Building RESTful APIs with Node.js and Express
REST (Representational State Transfer) APIs are the backbone of modern web applications. Whether you're building a mobile app, a single-page application, or integrating with third-party services, understanding how to design and implement robust APIs is essential. In this comprehensive guide, we'll build a production-ready RESTful API using Node.js and Express, covering everything from project setup to deployment best practices.
Node.js provides a non-blocking, event-driven architecture perfect for I/O-intensive operations like APIs. Express adds a minimal, flexible framework layer that makes routing, middleware, and HTTP handling intuitive. Together, they power APIs for companies like Netflix, PayPal, and LinkedIn.
Understanding REST Architecture
Before diving into code, let's understand what makes an API "RESTful". REST is an architectural style with specific constraints that, when followed, create scalable and maintainable APIs.
REST API architecture: Client-Server communication via HTTP
REST Principles
- Stateless: Each request contains all information needed. The server doesn't store client session data.
- Resource-Based: Everything is a resource (users, posts, products) identified by URIs.
- HTTP Methods: Standard methods define operations on resources.
- Uniform Interface: Consistent resource naming and response formats.
- JSON Format: Data is typically exchanged as JSON.
HTTP Methods Overview
Project Setup
Let's build a complete Users API from scratch. We'll set up the project structure, install dependencies, and configure Express.
Initialize the Project
Create a new directory and initialize npm with the necessary packages.
# Create project directory
mkdir express-rest-api
cd express-rest-api
# Initialize npm (creates package.json)
npm init -y
# Install dependencies
npm install express mongoose dotenv cors helmet morgan
# Install dev dependencies
npm install -D nodemon
# Create folder structure
mkdir -p src/{config,controllers,middleware,models,routes,utils}
- express: Web framework for Node.js
- mongoose: MongoDB ODM for data modeling
- dotenv: Environment variable management
- cors: Cross-Origin Resource Sharing middleware
- helmet: Security headers middleware
- morgan: HTTP request logging
- nodemon: Auto-restart during development
Project Structure
Organize your code for scalability and maintainability.
express-rest-api/
├── src/
│ ├── config/
│ │ └── database.js # Database connection
│ ├── controllers/
│ │ └── userController.js # Request handlers
│ ├── middleware/
│ │ ├── errorHandler.js # Global error handling
│ │ └── validate.js # Request validation
│ ├── models/
│ │ └── User.js # Mongoose schemas
│ ├── routes/
│ │ ├── index.js # Route aggregator
│ │ └── userRoutes.js # User endpoints
│ ├── utils/
│ │ └── ApiError.js # Custom error class
│ └── app.js # Express app setup
├── .env # Environment variables
├── .gitignore
├── package.json
└── server.js # Entry point
Building the Express Server
Let's start by creating the core Express application with essential middleware.
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const routes = require('./routes');
const errorHandler = require('./middleware/errorHandler');
const app = express();
// Security middleware
app.use(helmet());
// Enable CORS for all origins (configure for production)
app.use(cors());
// Parse JSON bodies
app.use(express.json({ limit: '10mb' }));
// Parse URL-encoded bodies
app.use(express.urlencoded({ extended: true }));
// HTTP request logging
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({
status: 'OK',
timestamp: new Date().toISOString()
});
});
// API routes
app.use('/api/v1', routes);
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
message: `Route ${req.originalUrl} not found`
});
});
// Global error handler (must be last)
app.use(errorHandler);
module.exports = app;
require('dotenv').config();
const app = require('./src/app');
const connectDB = require('./src/config/database');
const PORT = process.env.PORT || 3000;
// Connect to database and start server
const startServer = async () => {
try {
await connectDB();
app.listen(PORT, () => {
console.log(`
🚀 Server running in ${process.env.NODE_ENV || 'development'} mode
📡 API available at http://localhost:${PORT}/api/v1
❤️ Health check at http://localhost:${PORT}/health
`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('Unhandled Rejection:', err);
process.exit(1);
});
startServer();
NODE_ENV=development
PORT=3000
MONGODB_URI=mongodb://localhost:27017/express-api
JWT_SECRET=your-super-secret-key-change-in-production
Database Configuration
Setting up MongoDB with Mongoose for data persistence.
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.MONGODB_URI, {
// These options are no longer needed in Mongoose 6+
// but included for older versions
});
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.warn('MongoDB disconnected. Attempting to reconnect...');
});
mongoose.connection.on('reconnected', () => {
console.log('MongoDB reconnected');
});
return conn;
} catch (error) {
console.error('Database connection failed:', error.message);
throw error;
}
};
module.exports = connectDB;
Creating the User Model
Define a schema with validation, hooks, and instance methods.
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
const userSchema = new mongoose.Schema(
{
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters'],
maxlength: [50, 'Name cannot exceed 50 characters']
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
trim: true,
match: [
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
'Please provide a valid email'
]
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: [8, 'Password must be at least 8 characters'],
select: false // Don't include in queries by default
},
role: {
type: String,
enum: ['user', 'admin', 'moderator'],
default: 'user'
},
avatar: {
type: String,
default: null
},
isActive: {
type: Boolean,
default: true
},
lastLogin: {
type: Date,
default: null
}
},
{
timestamps: true, // Adds createdAt and updatedAt
toJSON: { virtuals: true },
toObject: { virtuals: true }
}
);
// Index for faster queries
userSchema.index({ email: 1 });
userSchema.index({ createdAt: -1 });
// Virtual for user's full profile URL
userSchema.virtual('profileUrl').get(function () {
return `/users/${this._id}`;
});
// Pre-save hook: Hash password before saving
userSchema.pre('save', async function (next) {
// Only hash if password is modified
if (!this.isModified('password')) return next();
// Hash password with bcrypt
const salt = await bcrypt.genSalt(12);
this.password = await bcrypt.hash(this.password, salt);
next();
});
// Instance method: Compare passwords
userSchema.methods.comparePassword = async function (candidatePassword) {
return bcrypt.compare(candidatePassword, this.password);
};
// Instance method: Return user without sensitive data
userSchema.methods.toPublicJSON = function () {
const obj = this.toObject();
delete obj.password;
delete obj.__v;
return obj;
};
// Static method: Find by email
userSchema.statics.findByEmail = function (email) {
return this.findOne({ email: email.toLowerCase() });
};
module.exports = mongoose.model('User', userSchema);
Routing and Controllers
Separate routes from business logic using the controller pattern.
Request flow: Router → Middleware → Controller → Response
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { validateUser, validateUserUpdate } = require('../middleware/validate');
// GET /api/v1/users - Get all users
router.get('/', userController.getAllUsers);
// GET /api/v1/users/:id - Get single user
router.get('/:id', userController.getUserById);
// POST /api/v1/users - Create new user
router.post('/', validateUser, userController.createUser);
// PUT /api/v1/users/:id - Update user (full)
router.put('/:id', validateUserUpdate, userController.updateUser);
// PATCH /api/v1/users/:id - Update user (partial)
router.patch('/:id', userController.patchUser);
// DELETE /api/v1/users/:id - Delete user
router.delete('/:id', userController.deleteUser);
module.exports = router;
const express = require('express');
const router = express.Router();
const userRoutes = require('./userRoutes');
// Mount routes
router.use('/users', userRoutes);
// Add more routes here as your API grows
// router.use('/posts', postRoutes);
// router.use('/products', productRoutes);
module.exports = router;
API Endpoints
Retrieve all users with pagination and filtering support.
Retrieve a single user by their unique ID.
Create a new user. Requires name, email, and password in request body.
Replace an existing user entirely. All fields required.
Delete a user by ID. Returns 204 No Content on success.
Implementing Controllers
Controllers contain the business logic for each endpoint. They handle the request, interact with the model, and send the response.
const User = require('../models/User');
const ApiError = require('../utils/ApiError');
// Helper for async error handling
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
/**
* @desc Get all users
* @route GET /api/v1/users
* @access Public
*/
exports.getAllUsers = asyncHandler(async (req, res) => {
// Pagination
const page = parseInt(req.query.page, 10) || 1;
const limit = parseInt(req.query.limit, 10) || 10;
const skip = (page - 1) * limit;
// Filtering
const filter = {};
if (req.query.role) filter.role = req.query.role;
if (req.query.isActive) filter.isActive = req.query.isActive === 'true';
// Sorting
const sort = req.query.sort || '-createdAt';
// Execute query
const [users, total] = await Promise.all([
User.find(filter)
.select('-password')
.sort(sort)
.skip(skip)
.limit(limit),
User.countDocuments(filter)
]);
res.status(200).json({
success: true,
count: users.length,
total,
page,
pages: Math.ceil(total / limit),
data: users
});
});
/**
* @desc Get single user by ID
* @route GET /api/v1/users/:id
* @access Public
*/
exports.getUserById = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id).select('-password');
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
res.status(200).json({
success: true,
data: user
});
});
/**
* @desc Create new user
* @route POST /api/v1/users
* @access Public
*/
exports.createUser = asyncHandler(async (req, res) => {
const { name, email, password, role } = req.body;
// Check if user already exists
const existingUser = await User.findByEmail(email);
if (existingUser) {
throw new ApiError(400, 'Email already registered');
}
// Create user
const user = await User.create({
name,
email,
password,
role: role || 'user'
});
res.status(201).json({
success: true,
message: 'User created successfully',
data: user.toPublicJSON()
});
});
/**
* @desc Update user (full replacement)
* @route PUT /api/v1/users/:id
* @access Public
*/
exports.updateUser = asyncHandler(async (req, res) => {
const { name, email, role, isActive } = req.body;
let user = await User.findById(req.params.id);
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
// Check email uniqueness if changed
if (email !== user.email) {
const emailExists = await User.findByEmail(email);
if (emailExists) {
throw new ApiError(400, 'Email already in use');
}
}
// Update fields
user.name = name;
user.email = email;
user.role = role || user.role;
user.isActive = isActive !== undefined ? isActive : user.isActive;
await user.save();
res.status(200).json({
success: true,
message: 'User updated successfully',
data: user.toPublicJSON()
});
});
/**
* @desc Partial update user
* @route PATCH /api/v1/users/:id
* @access Public
*/
exports.patchUser = asyncHandler(async (req, res) => {
const updates = req.body;
const allowedUpdates = ['name', 'email', 'role', 'isActive', 'avatar'];
// Filter out non-allowed fields
const filteredUpdates = Object.keys(updates)
.filter(key => allowedUpdates.includes(key))
.reduce((obj, key) => {
obj[key] = updates[key];
return obj;
}, {});
if (Object.keys(filteredUpdates).length === 0) {
throw new ApiError(400, 'No valid fields to update');
}
const user = await User.findByIdAndUpdate(
req.params.id,
filteredUpdates,
{ new: true, runValidators: true }
).select('-password');
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
res.status(200).json({
success: true,
message: 'User updated successfully',
data: user
});
});
/**
* @desc Delete user
* @route DELETE /api/v1/users/:id
* @access Public
*/
exports.deleteUser = asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new ApiError(404, `User not found with id: ${req.params.id}`);
}
await user.deleteOne();
res.status(204).send();
});
Error Handling
Proper error handling ensures your API responds gracefully to failures. We'll create a custom error class and a global error handler.
class ApiError extends Error {
constructor(statusCode, message, errors = []) {
super(message);
this.statusCode = statusCode;
this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
this.isOperational = true;
this.errors = errors;
Error.captureStackTrace(this, this.constructor);
}
// Factory methods for common errors
static badRequest(message, errors) {
return new ApiError(400, message, errors);
}
static unauthorized(message = 'Unauthorized') {
return new ApiError(401, message);
}
static forbidden(message = 'Forbidden') {
return new ApiError(403, message);
}
static notFound(message = 'Resource not found') {
return new ApiError(404, message);
}
static internal(message = 'Internal server error') {
return new ApiError(500, message);
}
}
module.exports = ApiError;
const ApiError = require('../utils/ApiError');
const errorHandler = (err, req, res, next) => {
let error = { ...err };
error.message = err.message;
error.stack = err.stack;
// Log error for debugging
console.error('Error:', {
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
});
// Mongoose bad ObjectId
if (err.name === 'CastError') {
error = new ApiError(400, `Invalid ${err.path}: ${err.value}`);
}
// Mongoose duplicate key
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
error = new ApiError(400, `${field} already exists`);
}
// Mongoose validation error
if (err.name === 'ValidationError') {
const messages = Object.values(err.errors).map(e => e.message);
error = new ApiError(400, 'Validation failed', messages);
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
error = new ApiError(401, 'Invalid token');
}
if (err.name === 'TokenExpiredError') {
error = new ApiError(401, 'Token expired');
}
// Send response
res.status(error.statusCode || 500).json({
success: false,
message: error.message || 'Internal server error',
errors: error.errors || [],
...(process.env.NODE_ENV === 'development' && {
stack: error.stack
})
});
};
module.exports = errorHandler;
Request Validation
Always validate incoming data before processing. This prevents bad data from reaching your database and provides helpful error messages.
const ApiError = require('../utils/ApiError');
// Validation helper
const validate = (schema) => {
return (req, res, next) => {
const errors = [];
for (const [field, rules] of Object.entries(schema)) {
const value = req.body[field];
// Required check
if (rules.required && (!value || value.toString().trim() === '')) {
errors.push(`${field} is required`);
continue;
}
// Skip other validations if field is empty and not required
if (!value && !rules.required) continue;
// Type checks
if (rules.type === 'email') {
const emailRegex = /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/;
if (!emailRegex.test(value)) {
errors.push(`${field} must be a valid email`);
}
}
// Min length
if (rules.minLength && value.length < rules.minLength) {
errors.push(`${field} must be at least ${rules.minLength} characters`);
}
// Max length
if (rules.maxLength && value.length > rules.maxLength) {
errors.push(`${field} cannot exceed ${rules.maxLength} characters`);
}
// Enum check
if (rules.enum && !rules.enum.includes(value)) {
errors.push(`${field} must be one of: ${rules.enum.join(', ')}`);
}
}
if (errors.length > 0) {
return next(new ApiError(400, 'Validation failed', errors));
}
next();
};
};
// User validation schemas
exports.validateUser = validate({
name: { required: true, minLength: 2, maxLength: 50 },
email: { required: true, type: 'email' },
password: { required: true, minLength: 8 },
role: { enum: ['user', 'admin', 'moderator'] }
});
exports.validateUserUpdate = validate({
name: { required: true, minLength: 2, maxLength: 50 },
email: { required: true, type: 'email' },
role: { enum: ['user', 'admin', 'moderator'] }
});
For production applications, consider using libraries like joi or express-validator for more robust validation with complex rules, nested objects, and custom validators.
Understanding Middleware
Middleware functions have access to the request, response, and the next middleware in the stack. They're the backbone of Express applications.
Middleware chain: Request flows through each middleware before reaching the route handler
Authentication Middleware Example
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const ApiError = require('../utils/ApiError');
// Protect routes - require authentication
exports.protect = async (req, res, next) => {
try {
let token;
// Get token from header
if (req.headers.authorization?.startsWith('Bearer')) {
token = req.headers.authorization.split(' ')[1];
}
if (!token) {
throw new ApiError(401, 'Access denied. No token provided.');
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Get user from token
const user = await User.findById(decoded.id).select('-password');
if (!user) {
throw new ApiError(401, 'User no longer exists');
}
if (!user.isActive) {
throw new ApiError(401, 'User account is deactivated');
}
// Attach user to request
req.user = user;
next();
} catch (error) {
next(error);
}
};
// Restrict to certain roles
exports.restrictTo = (...roles) => {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return next(
new ApiError(403, 'You do not have permission to perform this action')
);
}
next();
};
};
// Usage in routes:
// router.get('/admin', protect, restrictTo('admin'), adminController);
Rate Limiting Middleware
// Simple in-memory rate limiter
// For production, use redis-based solution
const rateLimit = (options = {}) => {
const {
windowMs = 15 * 60 * 1000, // 15 minutes
max = 100, // Limit each IP to 100 requests per window
message = 'Too many requests, please try again later.'
} = options;
const requests = new Map();
// Cleanup old entries periodically
setInterval(() => {
const now = Date.now();
for (const [key, data] of requests.entries()) {
if (now - data.startTime > windowMs) {
requests.delete(key);
}
}
}, windowMs);
return (req, res, next) => {
const key = req.ip;
const now = Date.now();
const record = requests.get(key);
if (!record) {
requests.set(key, { count: 1, startTime: now });
return next();
}
if (now - record.startTime > windowMs) {
// Reset window
requests.set(key, { count: 1, startTime: now });
return next();
}
record.count++;
// Set rate limit headers
res.set({
'X-RateLimit-Limit': max,
'X-RateLimit-Remaining': Math.max(0, max - record.count),
'X-RateLimit-Reset': new Date(record.startTime + windowMs).toISOString()
});
if (record.count > max) {
return res.status(429).json({
success: false,
message
});
}
next();
};
};
module.exports = rateLimit;
// Usage:
// app.use('/api', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }));
Testing the API
Let's test our API using curl commands. Make sure your server is running with npm run dev.
# Create a user
curl -X POST http://localhost:3000/api/v1/users \
-H "Content-Type: application/json" \
-d '{
"name": "John Doe",
"email": "john@example.com",
"password": "securepassword123"
}'
# Get all users
curl http://localhost:3000/api/v1/users
# Get all users with pagination
curl "http://localhost:3000/api/v1/users?page=1&limit=5"
# Get single user (replace :id with actual ID)
curl http://localhost:3000/api/v1/users/65abc123def456
# Update user
curl -X PUT http://localhost:3000/api/v1/users/65abc123def456 \
-H "Content-Type: application/json" \
-d '{
"name": "John Updated",
"email": "john.updated@example.com",
"role": "admin"
}'
# Partial update
curl -X PATCH http://localhost:3000/api/v1/users/65abc123def456 \
-H "Content-Type: application/json" \
-d '{"isActive": false}'
# Delete user
curl -X DELETE http://localhost:3000/api/v1/users/65abc123def456
- Postman: Feature-rich GUI for testing APIs
- Insomnia: Clean, fast REST client
- Thunder Client: VS Code extension for API testing
- HTTPie: User-friendly command-line HTTP client
API Response Standards
Consistent response formats make your API predictable and easier to consume.
| Status Code | Meaning | When to Use |
|---|---|---|
| 200 OK | Success | GET, PUT, PATCH successful |
| 201 Created | Resource created | POST successful |
| 204 No Content | Success, no body | DELETE successful |
| 400 Bad Request | Invalid input | Validation failed |
| 401 Unauthorized | Not authenticated | Missing/invalid token |
| 403 Forbidden | Not authorized | Insufficient permissions |
| 404 Not Found | Resource doesn't exist | ID not found |
| 500 Internal Error | Server error | Unexpected failures |
Best Practices Summary
Production Checklist
- Version your API: Use
/api/v1/prefix for easy upgrades - Use nouns for resources:
/usersnot/getUsers - Support filtering, sorting, pagination: Query params for list endpoints
- Implement rate limiting: Protect against abuse
- Use HTTPS: Encrypt all traffic in production
- Add request logging: Morgan or similar for debugging
- Document your API: Swagger/OpenAPI specification
- Write tests: Unit and integration tests with Jest
- Use environment variables: Never hardcode secrets
- Implement health checks:
/healthendpoint for monitoring
- Always validate and sanitize user input
- Use parameterized queries to prevent injection
- Implement proper authentication and authorization
- Set security headers with helmet
- Keep dependencies updated
- Never expose stack traces in production
Next Steps
You now have a solid foundation for building RESTful APIs with Node.js and Express! Here's what to explore next:
- Authentication: Implement JWT-based authentication with refresh tokens
- File Uploads: Handle file uploads with Multer
- API Documentation: Add Swagger/OpenAPI documentation
- Testing: Write tests with Jest and Supertest
- Caching: Implement Redis caching for performance
- WebSockets: Add real-time features with Socket.io
- GraphQL: Consider GraphQL for complex data requirements
Building APIs is both an art and a science. Focus on consistency, security, and developer experience. Happy coding!
