Node.js Express API: Complete Guide to Building RESTful APIs
Express.js is the most widely used web framework in the Node.js ecosystem, powering everything from simple REST APIs to complex microservice architectures. Its minimalist design, powerful middleware system, and massive community make it the go-to choice for backend JavaScript developers. Whether you are building a startup MVP, a production API serving millions of requests, or a microservice in a larger system, Express provides exactly the right amount of structure without imposing unnecessary opinions.
This guide walks through every aspect of building production-ready APIs with Node.js and Express. We start from project setup and progress through routing, middleware, CRUD operations, authentication, database integration, error handling, testing, security, and deployment. Every section includes working code that you can adapt directly into your projects.
Table of Contents
- Setting Up a Node.js Project
- Express.js Fundamentals
- REST API Design Principles
- CRUD Operations with Express
- Middleware Deep Dive
- Request Validation
- Authentication with JWT
- Database Integration
- Error Handling Patterns
- API Versioning
- Rate Limiting and Security
- Testing with Jest and Supertest
- Project Structure Best Practices
- Deployment Considerations
- Frequently Asked Questions
1. Setting Up a Node.js Project
Every Express project starts with initializing a Node.js project and installing dependencies. Here is the complete setup from an empty directory to a running server.
# Create project directory and initialize
mkdir my-api && cd my-api
npm init -y
# Install core dependencies
npm install express dotenv cors
# Install development dependencies
npm install -D nodemon jest supertest
# Create project structure
mkdir -p src/{routes,controllers,middleware,models,services,utils,config}
touch src/app.js src/server.js .env .env.example
Configure your package.json scripts for development and production:
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest --verbose --forceExit",
"test:watch": "jest --watch"
}
}
Set up environment variables in .env:
# .env
NODE_ENV=development
PORT=3000
DATABASE_URL=mongodb://localhost:27017/myapp
JWT_SECRET=your-secret-key-change-in-production
JWT_EXPIRES_IN=7d
Create the Express application in src/app.js and the server entry point in src/server.js separately. This separation is critical — it allows you to import the app in tests without starting the HTTP server.
// src/app.js
const express = require('express');
const cors = require('cors');
require('dotenv').config();
const app = express();
// Core middleware
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
module.exports = app;
// src/server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} in ${process.env.NODE_ENV} mode`);
});
2. Express.js Fundamentals
Express revolves around three core concepts: routing (matching URLs to handler functions), middleware (functions that process requests before they reach route handlers), and the request/response cycle.
Routing
Routes map HTTP methods and URL patterns to handler functions. Express supports all HTTP methods and flexible URL patterns including parameters, wildcards, and regex.
const express = require('express');
const router = express.Router();
// Basic routes
router.get('/users', listUsers); // GET /users
router.post('/users', createUser); // POST /users
router.get('/users/:id', getUser); // GET /users/123
router.put('/users/:id', updateUser); // PUT /users/123
router.patch('/users/:id', patchUser); // PATCH /users/123
router.delete('/users/:id', deleteUser); // DELETE /users/123
// Route parameters are available on req.params
router.get('/users/:userId/posts/:postId', (req, res) => {
const { userId, postId } = req.params;
res.json({ userId, postId });
});
// Query parameters are on req.query
// GET /search?q=express&page=2&limit=20
router.get('/search', (req, res) => {
const { q, page = 1, limit = 10 } = req.query;
res.json({ query: q, page: Number(page), limit: Number(limit) });
});
// Route chaining for the same path
router.route('/articles')
.get(listArticles)
.post(authenticate, createArticle);
router.route('/articles/:id')
.get(getArticle)
.put(authenticate, updateArticle)
.delete(authenticate, authorize('admin'), deleteArticle);
Request and Response Objects
router.post('/users', (req, res) => {
// Request properties
console.log(req.body); // parsed JSON body
console.log(req.params); // URL parameters
console.log(req.query); // query string
console.log(req.headers); // request headers
console.log(req.method); // 'POST'
console.log(req.path); // '/users'
console.log(req.ip); // client IP address
console.log(req.get('Content-Type')); // specific header
// Response methods
res.status(201).json({ id: 1, name: 'Alice' }); // JSON response
// res.status(204).send(); // No content
// res.status(301).redirect('/new'); // Redirect
// res.status(404).json({ error: 'Not found' });
});
3. REST API Design Principles
A well-designed REST API follows consistent conventions that make it predictable and easy to consume. These principles are not Express-specific, but Express makes it straightforward to implement them.
- Use nouns for resources, not verbs —
/usersnot/getUsers. The HTTP method indicates the action. - Use plural nouns —
/usersnot/user. Even when accessing a single resource:/users/123. - Nest related resources —
/users/123/postsfor posts belonging to user 123. - Use HTTP status codes correctly — 200 (OK), 201 (Created), 204 (No Content), 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404 (Not Found), 422 (Validation Error), 500 (Server Error).
- Support filtering, sorting, and pagination —
/users?role=admin&sort=-createdAt&page=2&limit=20. - Return consistent response shapes — always wrap data in a standard envelope.
// Consistent response format
// Success:
{
"status": "success",
"data": { "user": { "id": 1, "name": "Alice" } }
}
// List with pagination:
{
"status": "success",
"results": 20,
"data": { "users": [...] },
"pagination": { "page": 2, "limit": 20, "total": 156 }
}
// Error:
{
"status": "error",
"message": "User not found",
"code": "USER_NOT_FOUND"
}
4. CRUD Operations with Express
Here is a complete CRUD implementation for a users resource. This pattern applies to virtually any resource in your API.
// src/controllers/userController.js
const User = require('../models/User');
// GET /api/users
exports.getUsers = async (req, res, next) => {
try {
const { page = 1, limit = 10, sort = '-createdAt', role } = req.query;
const filter = {};
if (role) filter.role = role;
const users = await User.find(filter)
.sort(sort)
.skip((page - 1) * limit)
.limit(Number(limit))
.select('-password');
const total = await User.countDocuments(filter);
res.json({
status: 'success',
results: users.length,
data: { users },
pagination: {
page: Number(page),
limit: Number(limit),
total,
pages: Math.ceil(total / limit)
}
});
} catch (err) {
next(err);
}
};
// GET /api/users/:id
exports.getUser = async (req, res, next) => {
try {
const user = await User.findById(req.params.id).select('-password');
if (!user) {
return res.status(404).json({
status: 'error',
message: 'User not found'
});
}
res.json({ status: 'success', data: { user } });
} catch (err) {
next(err);
}
};
// POST /api/users
exports.createUser = async (req, res, next) => {
try {
const { name, email, password, role } = req.body;
const user = await User.create({ name, email, password, role });
// Remove password from response
const userObj = user.toObject();
delete userObj.password;
res.status(201).json({ status: 'success', data: { user: userObj } });
} catch (err) {
next(err);
}
};
// PUT /api/users/:id
exports.updateUser = async (req, res, next) => {
try {
const { name, email, role } = req.body;
const user = await User.findByIdAndUpdate(
req.params.id,
{ name, email, role },
{ new: true, runValidators: true }
).select('-password');
if (!user) {
return res.status(404).json({
status: 'error',
message: 'User not found'
});
}
res.json({ status: 'success', data: { user } });
} catch (err) {
next(err);
}
};
// DELETE /api/users/:id
exports.deleteUser = async (req, res, next) => {
try {
const user = await User.findByIdAndDelete(req.params.id);
if (!user) {
return res.status(404).json({
status: 'error',
message: 'User not found'
});
}
res.status(204).send();
} catch (err) {
next(err);
}
};
Wire the controller to routes:
// src/routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { authenticate, authorize } = require('../middleware/auth');
const { validateUser } = require('../middleware/validate');
router.route('/')
.get(userController.getUsers)
.post(authenticate, validateUser, userController.createUser);
router.route('/:id')
.get(userController.getUser)
.put(authenticate, validateUser, userController.updateUser)
.delete(authenticate, authorize('admin'), userController.deleteUser);
module.exports = router;
5. Middleware Deep Dive
Middleware functions are the backbone of Express. They have access to the request object, the response object, and the next middleware function. They can execute code, modify req/res, end the request-response cycle, or call the next middleware.
Built-in Middleware
// Parse JSON bodies
app.use(express.json({ limit: '10mb' }));
// Parse URL-encoded bodies (form submissions)
app.use(express.urlencoded({ extended: true }));
// Serve static files
app.use(express.static('public'));
Custom Middleware
// Request logging middleware
const requestLogger = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
};
// Request ID middleware (for tracing)
const { randomUUID } = require('crypto');
const requestId = (req, res, next) => {
req.id = req.headers['x-request-id'] || randomUUID();
res.setHeader('X-Request-ID', req.id);
next();
};
// Apply globally
app.use(requestId);
app.use(requestLogger);
// Apply to specific routes only
app.use('/api/admin', authenticate, authorize('admin'));
Async Handler Wrapper
Writing try-catch in every async route handler is repetitive. A wrapper function eliminates this boilerplate:
// src/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
// Usage in controllers - no more try-catch
const asyncHandler = require('../utils/asyncHandler');
exports.getUsers = asyncHandler(async (req, res) => {
const users = await User.find().select('-password');
res.json({ status: 'success', data: { users } });
// Errors automatically forwarded to error handler
});
6. Request Validation
Never trust client input. Validate every request body, parameter, and query string before processing. The two most popular options are express-validator (lightweight, Express-specific) and Joi (schema-based, framework-agnostic).
With express-validator
npm install express-validator
const { body, param, query, validationResult } = require('express-validator');
// Reusable validation error handler
const validate = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(422).json({
status: 'error',
message: 'Validation failed',
errors: errors.array().map(e => ({
field: e.path,
message: e.msg
}))
});
}
next();
};
// Validation rules for user creation
const validateCreateUser = [
body('name')
.trim()
.notEmpty().withMessage('Name is required')
.isLength({ min: 2, max: 50 }).withMessage('Name must be 2-50 characters'),
body('email')
.trim()
.notEmpty().withMessage('Email is required')
.isEmail().withMessage('Must be a valid email')
.normalizeEmail(),
body('password')
.notEmpty().withMessage('Password is required')
.isLength({ min: 8 }).withMessage('Password must be at least 8 characters')
.matches(/\d/).withMessage('Password must contain a number')
.matches(/[A-Z]/).withMessage('Password must contain an uppercase letter'),
body('role')
.optional()
.isIn(['user', 'admin']).withMessage('Role must be user or admin'),
validate // Run validation check after all rules
];
// Apply to route
router.post('/users', validateCreateUser, userController.createUser);
With Joi
npm install joi
const Joi = require('joi');
const userSchema = Joi.object({
name: Joi.string().trim().min(2).max(50).required(),
email: Joi.string().email().required(),
password: Joi.string().min(8)
.pattern(/[A-Z]/, 'uppercase letter')
.pattern(/\d/, 'number')
.required(),
role: Joi.string().valid('user', 'admin').default('user')
});
// Generic Joi validation middleware
const validateBody = (schema) => (req, res, next) => {
const { error, value } = schema.validate(req.body, { abortEarly: false });
if (error) {
return res.status(422).json({
status: 'error',
message: 'Validation failed',
errors: error.details.map(d => ({
field: d.path.join('.'),
message: d.message
}))
});
}
req.body = value; // Use sanitized values
next();
};
router.post('/users', validateBody(userSchema), userController.createUser);
7. Authentication with JWT
JSON Web Tokens (JWT) are the standard for stateless API authentication. The server issues a signed token upon login, and the client sends it with every subsequent request in the Authorization header.
npm install jsonwebtoken bcrypt
// src/controllers/authController.js
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const User = require('../models/User');
const asyncHandler = require('../utils/asyncHandler');
const signToken = (userId) => {
return jwt.sign({ id: userId }, process.env.JWT_SECRET, {
expiresIn: process.env.JWT_EXPIRES_IN || '7d'
});
};
// POST /api/auth/register
exports.register = asyncHandler(async (req, res) => {
const { name, email, password } = req.body;
// Check if user exists
const existing = await User.findOne({ email });
if (existing) {
return res.status(409).json({
status: 'error',
message: 'Email already registered'
});
}
// Hash password and create user
const hashedPassword = await bcrypt.hash(password, 12);
const user = await User.create({ name, email, password: hashedPassword });
const token = signToken(user._id);
res.status(201).json({
status: 'success',
data: { token, user: { id: user._id, name, email } }
});
});
// POST /api/auth/login
exports.login = asyncHandler(async (req, res) => {
const { email, password } = req.body;
const user = await User.findOne({ email }).select('+password');
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({
status: 'error',
message: 'Invalid email or password'
});
}
const token = signToken(user._id);
res.json({
status: 'success',
data: { token, user: { id: user._id, name: user.name, email } }
});
});
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const User = require('../models/User');
exports.authenticate = async (req, res, next) => {
try {
// Extract token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
status: 'error',
message: 'Authentication required'
});
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Verify user still exists
const user = await User.findById(decoded.id).select('-password');
if (!user) {
return res.status(401).json({
status: 'error',
message: 'User no longer exists'
});
}
req.user = user;
next();
} catch (err) {
return res.status(401).json({
status: 'error',
message: 'Invalid or expired token'
});
}
};
// Role-based authorization
exports.authorize = (...roles) => (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({
status: 'error',
message: 'You do not have permission to perform this action'
});
}
next();
};
8. Database Integration
MongoDB with Mongoose
npm install mongoose
// src/config/database.js
const mongoose = require('mongoose');
const connectDB = async () => {
try {
const conn = await mongoose.connect(process.env.DATABASE_URL, {
maxPoolSize: 10
});
console.log(`MongoDB connected: ${conn.connection.host}`);
} catch (err) {
console.error('Database connection error:', err.message);
process.exit(1);
}
};
module.exports = connectDB;
// src/models/User.js
const mongoose = require('mongoose');
const userSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Name is required'],
trim: true,
minlength: [2, 'Name must be at least 2 characters']
},
email: {
type: String,
required: [true, 'Email is required'],
unique: true,
lowercase: true,
match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email']
},
password: {
type: String,
required: [true, 'Password is required'],
minlength: 8,
select: false // Excluded from queries by default
},
role: {
type: String,
enum: ['user', 'admin'],
default: 'user'
}
}, {
timestamps: true, // Adds createdAt and updatedAt
toJSON: { virtuals: true },
toObject: { virtuals: true }
});
// Index for common queries
userSchema.index({ email: 1 });
userSchema.index({ role: 1, createdAt: -1 });
// Virtual field
userSchema.virtual('posts', {
ref: 'Post',
localField: '_id',
foreignField: 'author'
});
module.exports = mongoose.model('User', userSchema);
PostgreSQL with Knex
npm install knex pg
// knexfile.js
module.exports = {
development: {
client: 'pg',
connection: process.env.DATABASE_URL,
migrations: { directory: './src/db/migrations' },
seeds: { directory: './src/db/seeds' }
},
production: {
client: 'pg',
connection: { connectionString: process.env.DATABASE_URL, ssl: { rejectUnauthorized: false } },
pool: { min: 2, max: 10 },
migrations: { directory: './src/db/migrations' }
}
};
// Create a migration: npx knex migrate:make create_users
// src/db/migrations/20260212_create_users.js
exports.up = function(knex) {
return knex.schema.createTable('users', (table) => {
table.increments('id').primary();
table.string('name', 50).notNullable();
table.string('email', 255).notNullable().unique();
table.string('password', 255).notNullable();
table.enum('role', ['user', 'admin']).defaultTo('user');
table.timestamps(true, true); // created_at, updated_at
table.index(['email']);
table.index(['role', 'created_at']);
});
};
exports.down = function(knex) {
return knex.schema.dropTable('users');
};
// Query with Knex
const knex = require('../config/database');
// SELECT with pagination
const users = await knex('users')
.select('id', 'name', 'email', 'role', 'created_at')
.where('role', 'admin')
.orderBy('created_at', 'desc')
.limit(20)
.offset(0);
// INSERT
const [user] = await knex('users')
.insert({ name, email, password: hashedPassword })
.returning('*');
// UPDATE
await knex('users').where('id', id).update({ name, email });
// DELETE
await knex('users').where('id', id).del();
9. Error Handling Patterns
Centralized error handling keeps your controllers clean and ensures every error is caught and returned in a consistent format. This is one of the most important architectural decisions in an Express API.
// src/utils/AppError.js - Custom error class
class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code || 'ERROR';
this.isOperational = true; // Distinguishes expected errors from bugs
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// Usage in controllers:
const AppError = require('../utils/AppError');
exports.getUser = asyncHandler(async (req, res, next) => {
const user = await User.findById(req.params.id);
if (!user) {
throw new AppError('User not found', 404, 'USER_NOT_FOUND');
}
res.json({ status: 'success', data: { user } });
});
// src/middleware/errorHandler.js - Global error handler
const errorHandler = (err, req, res, next) => {
err.statusCode = err.statusCode || 500;
// Mongoose duplicate key error
if (err.code === 11000) {
const field = Object.keys(err.keyValue)[0];
err.statusCode = 409;
err.message = `Duplicate value for ${field}`;
}
// Mongoose validation error
if (err.name === 'ValidationError') {
err.statusCode = 422;
err.message = Object.values(err.errors).map(e => e.message).join(', ');
}
// Mongoose bad ObjectId
if (err.name === 'CastError') {
err.statusCode = 400;
err.message = `Invalid ${err.path}: ${err.value}`;
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
err.statusCode = 401;
err.message = 'Invalid token';
}
if (err.name === 'TokenExpiredError') {
err.statusCode = 401;
err.message = 'Token expired';
}
// Send response
const response = {
status: 'error',
message: err.message
};
// Include stack trace in development only
if (process.env.NODE_ENV === 'development') {
response.stack = err.stack;
}
console.error(`[ERROR] ${req.method} ${req.originalUrl}:`, err.message);
res.status(err.statusCode).json(response);
};
module.exports = errorHandler;
// Register it LAST in app.js (after all routes)
app.use(errorHandler);
Always add a 404 handler for unmatched routes before the error handler:
// In app.js, after all route registrations
app.all('*', (req, res) => {
res.status(404).json({
status: 'error',
message: `Route ${req.originalUrl} not found`
});
});
app.use(errorHandler);
10. API Versioning
API versioning allows you to evolve your API without breaking existing clients. The most common approach is URL prefix versioning because it is explicit and easy to implement.
// src/app.js - URL prefix versioning
const v1Routes = require('./routes/v1');
const v2Routes = require('./routes/v2');
app.use('/api/v1', v1Routes);
app.use('/api/v2', v2Routes);
// src/routes/v1/index.js
const router = require('express').Router();
router.use('/users', require('./userRoutes'));
router.use('/posts', require('./postRoutes'));
router.use('/auth', require('./authRoutes'));
module.exports = router;
// src/routes/v2/index.js - v2 can import v1 handlers and override only what changed
const router = require('express').Router();
const v1UserController = require('../../controllers/v1/userController');
const v2UserController = require('../../controllers/v2/userController');
// v2 uses new getUsers but keeps v1 createUser
router.get('/users', v2UserController.getUsers);
router.post('/users', v1UserController.createUser);
module.exports = router;
Alternative approaches include header versioning (Accept: application/vnd.myapp.v2+json) and query parameter versioning (/users?version=2). URL prefix is recommended because it is the most transparent and cacheable.
11. Rate Limiting and Security
Security is not optional. Every production API needs these protections at minimum.
npm install helmet express-rate-limit express-mongo-sanitize hpp
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const mongoSanitize = require('express-mongo-sanitize');
const hpp = require('hpp');
// Security HTTP headers
app.use(helmet());
// Rate limiting - global
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: {
status: 'error',
message: 'Too many requests, please try again later'
}
});
app.use('/api', globalLimiter);
// Stricter rate limit for authentication routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // Only 10 login attempts per 15 minutes
message: {
status: 'error',
message: 'Too many login attempts, please try again in 15 minutes'
}
});
app.use('/api/auth', authLimiter);
// Prevent NoSQL injection
app.use(mongoSanitize());
// Prevent HTTP parameter pollution
app.use(hpp({
whitelist: ['sort', 'fields', 'page', 'limit']
}));
// CORS configuration for specific origins
const cors = require('cors');
app.use(cors({
origin: process.env.NODE_ENV === 'production'
? ['https://myapp.com', 'https://admin.myapp.com']
: '*',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
12. Testing with Jest and Supertest
Testing ensures your API works correctly and stays working as you make changes. Use Jest as the test runner and Supertest for making HTTP requests against your Express app without starting a real server.
npm install -D jest supertest
// tests/users.test.js
const request = require('supertest');
const app = require('../src/app');
const mongoose = require('mongoose');
const User = require('../src/models/User');
let authToken;
beforeAll(async () => {
await mongoose.connect(process.env.TEST_DATABASE_URL);
});
afterAll(async () => {
await mongoose.connection.dropDatabase();
await mongoose.connection.close();
});
beforeEach(async () => {
await User.deleteMany({});
// Create a test user and get auth token
const res = await request(app)
.post('/api/auth/register')
.send({ name: 'Test User', email: 'test@example.com', password: 'Password123' });
authToken = res.body.data.token;
});
describe('GET /api/users', () => {
it('should return a list of users', async () => {
const res = await request(app)
.get('/api/users')
.expect(200);
expect(res.body.status).toBe('success');
expect(Array.isArray(res.body.data.users)).toBe(true);
expect(res.body.data.users[0]).not.toHaveProperty('password');
});
it('should support pagination', async () => {
const res = await request(app)
.get('/api/users?page=1&limit=5')
.expect(200);
expect(res.body.pagination).toHaveProperty('page', 1);
expect(res.body.pagination).toHaveProperty('limit', 5);
expect(res.body.pagination).toHaveProperty('total');
});
});
describe('POST /api/users', () => {
it('should create a user with valid data', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Alice', email: 'alice@example.com', password: 'Secure123' })
.expect(201);
expect(res.body.data.user).toHaveProperty('name', 'Alice');
expect(res.body.data.user).toHaveProperty('email', 'alice@example.com');
expect(res.body.data.user).not.toHaveProperty('password');
});
it('should reject invalid email', async () => {
const res = await request(app)
.post('/api/users')
.set('Authorization', `Bearer ${authToken}`)
.send({ name: 'Alice', email: 'not-an-email', password: 'Secure123' })
.expect(422);
expect(res.body.status).toBe('error');
});
it('should require authentication', async () => {
await request(app)
.post('/api/users')
.send({ name: 'Alice', email: 'alice@example.com', password: 'Secure123' })
.expect(401);
});
});
describe('DELETE /api/users/:id', () => {
it('should return 404 for non-existent user', async () => {
const fakeId = new mongoose.Types.ObjectId();
await request(app)
.delete(`/api/users/${fakeId}`)
.set('Authorization', `Bearer ${authToken}`)
.expect(404);
});
});
Add a Jest configuration to package.json:
{
"jest": {
"testEnvironment": "node",
"testMatch": ["**/tests/**/*.test.js"],
"setupFiles": ["dotenv/config"],
"testTimeout": 10000
}
}
13. Project Structure Best Practices
A well-organized project structure scales as your API grows. Here is the recommended layout for a production Express API:
my-api/
src/
app.js # Express app setup and middleware
server.js # HTTP server, port binding, DB connection
config/
database.js # Database connection logic
index.js # Environment config loader
middleware/
auth.js # authenticate, authorize
errorHandler.js # Global error handler
validate.js # Validation middleware factory
rateLimiter.js # Rate limiting config
routes/
v1/
index.js # Route aggregator for v1
userRoutes.js
postRoutes.js
authRoutes.js
controllers/
authController.js
userController.js
postController.js
services/
authService.js # Business logic for auth
userService.js # Business logic for users
emailService.js # Email sending logic
models/
User.js
Post.js
utils/
AppError.js # Custom error class
asyncHandler.js # Async wrapper
apiFeatures.js # Pagination, filtering, sorting helpers
tests/
users.test.js
auth.test.js
setup.js # Test setup and teardown
.env
.env.example
package.json
knexfile.js # If using Knex/PostgreSQL
Key principles for structuring your project:
- Separate app from server — Export the Express app from
app.jsfor testing. Onlyserver.jscallsapp.listen(). - Keep controllers thin — Controllers parse requests and call services. Business logic lives in the service layer.
- One responsibility per file — Each model, controller, and route file handles a single resource.
- Group by feature at scale — For very large APIs, switch to feature-based folders where each feature (users/, posts/) contains its own routes, controller, service, and model.
14. Deployment Considerations
Moving from development to production requires attention to performance, reliability, and observability.
Process Management
# Use PM2 for production process management
npm install -g pm2
# Start with clustering (uses all CPU cores)
pm2 start src/server.js -i max --name my-api
# Common PM2 commands
pm2 list # Show running processes
pm2 logs my-api # View logs
pm2 restart my-api # Restart
pm2 monit # Real-time monitoring
Graceful Shutdown
// src/server.js
const server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// Handle graceful shutdown
const shutdown = (signal) => {
console.log(`${signal} received. Shutting down gracefully...`);
server.close(() => {
console.log('HTTP server closed');
// Close database connections
mongoose.connection.close(false, () => {
console.log('Database connection closed');
process.exit(0);
});
});
// Force shutdown after 10 seconds
setTimeout(() => {
console.error('Forced shutdown');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
// Handle unhandled promise rejections
process.on('unhandledRejection', (err) => {
console.error('UNHANDLED REJECTION:', err.message);
server.close(() => process.exit(1));
});
Docker Deployment
# Dockerfile
FROM node:20-alpine
WORKDIR /app
# Install dependencies first (layer caching)
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY src/ ./src/
# Run as non-root user
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_URL=mongodb://mongo:27017/myapp
- JWT_SECRET=${JWT_SECRET}
depends_on:
- mongo
restart: unless-stopped
mongo:
image: mongo:7
volumes:
- mongo_data:/data/db
restart: unless-stopped
volumes:
mongo_data:
Production Checklist
- Set
NODE_ENV=production— Express disables verbose errors and enables view caching - Use environment variables for all secrets and configuration
- Enable HTTPS (terminate at reverse proxy like Nginx or Traefik)
- Set up structured logging with Winston or Pino (not console.log)
- Add health check endpoints for load balancers (
/health) - Implement graceful shutdown for zero-downtime deploys
- Run
npm auditregularly and update dependencies - Use compression middleware for response gzip/brotli encoding
Frequently Asked Questions
What is the difference between Express.js and Node.js?
Node.js is a JavaScript runtime built on Chrome's V8 engine that lets you run JavaScript on the server. It provides low-level APIs for networking, file system access, and HTTP. Express.js is a minimal web framework that runs on top of Node.js, providing a cleaner API for routing, middleware, request/response handling, and building web applications. You can build HTTP servers with just Node.js, but Express saves you from writing repetitive boilerplate for parsing bodies, routing URLs, and managing middleware chains. Think of Node.js as the engine and Express as the car built around it.
How do I handle errors properly in an Express API?
Express uses error-handling middleware with four parameters: (err, req, res, next). For async handlers, wrap them in try-catch and pass errors to next(), or use an asyncHandler wrapper that catches promise rejections automatically. Define a centralized error handler at the end of your middleware chain that formats all error responses consistently. Create a custom AppError class with status codes and error codes. In production, never expose stack traces to clients. Log errors with a structured logger like Winston or Pino for debugging.
Should I use MongoDB or PostgreSQL with Express.js?
Choose based on your data model. MongoDB with Mongoose is excellent for flexible, document-oriented data and rapid prototyping. Its JSON-like documents map naturally to JavaScript objects. PostgreSQL with Knex or Prisma is better for complex relationships, transactions, and strong data integrity. PostgreSQL is generally the safer default for production because relational data is more common than you might think, and migrating from MongoDB to PostgreSQL later is painful. Many teams start with MongoDB for speed and regret it when they need joins and transactions.
How do I secure a Node.js Express API in production?
Security involves multiple layers. Use helmet for security HTTP headers. Enable CORS with restricted origins. Implement rate limiting with express-rate-limit. Validate all input with express-validator or Joi. Use bcrypt for password hashing with a cost factor of at least 12. Implement JWT with short-lived access tokens and refresh tokens. Always use HTTPS. Sanitize input against NoSQL injection (express-mongo-sanitize) and XSS. Keep dependencies updated with npm audit. Never hardcode secrets — use environment variables.
What is the best project structure for a large Express.js API?
Use a modular structure organized by feature. Each feature module (users, products, orders) contains its own routes, controllers, services, models, and validation. Keep controllers thin — they parse requests and call service functions where business logic lives. Separate app.js (Express setup) from server.js (HTTP server startup) so you can import the app in tests. Use a config directory for environment settings, middleware directory for shared middleware, and utils directory for helpers. This structure scales because adding a feature means adding a directory, not touching existing code.
How do I test an Express.js API?
Use Jest as the test runner and Supertest for HTTP requests against your Express app without starting a real server. Export your app from app.js and pass it to Supertest: const response = await request(app).get('/api/users'). Write unit tests for services and utilities, and integration tests for endpoints. Mock external dependencies with Jest mocks. Use a separate test database or in-memory database. Structure tests to mirror your source directory. Run tests in CI/CD pipelines to catch regressions before deployment.
Conclusion
Building production-ready APIs with Node.js and Express requires more than knowing the framework's syntax. It requires understanding middleware composition, proper error handling, input validation, authentication flows, database patterns, testing strategies, and security hardening. The patterns in this guide represent industry best practices that have been refined across thousands of production deployments.
If you are starting a new API project, follow this progression: set up the project structure with separate app and server files, add core middleware (JSON parsing, CORS, helmet), implement your first CRUD resource with proper error handling, add input validation, implement authentication, write tests for every endpoint, and then layer on rate limiting and security. Each step builds on the previous one, and by the end you have a production-ready foundation.
The Node.js ecosystem moves fast, but Express's minimalist philosophy has kept it relevant for over a decade. The middleware pattern, route handling, and request-response cycle you learn here apply whether you are building a monolith, microservices, or a serverless API. Master these fundamentals and you can build anything.
Related Resources
- Flask REST API: Complete Guide — compare Node.js/Express with Python's Flask for API development
- JavaScript Debugging: Complete Guide — debug your Node.js applications effectively
- TypeScript Generics: Complete Guide — add type safety to your Express APIs with TypeScript
- JSON: The Complete Guide — understand the data format your API speaks