Express remains the most popular Node.js framework for building REST APIs, and for good reason. It is minimal, flexible, and has a massive ecosystem of middleware. This guide takes you from project setup to a production-ready API with routing, middleware, validation, error handling, and JWT authentication. Every concept comes with working code you can adapt to your projects.
Project Setup and Express Configuration
Initialize a new Node.js project and install the core dependencies.
mkdir bookstore-api && cd bookstore-api
npm init -y
npm install express cors helmet morgan dotenv jsonwebtoken bcryptjs
npm install -D nodemon
Create the entry point with essential middleware configured. Helmet adds security headers, CORS handles cross-origin requests, and Morgan logs HTTP requests.
// src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
require('dotenv').config();
const app = express();
// Middleware
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') || '*' }));
app.use(morgan('combined'));
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true }));
// Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/books', require('./routes/books'));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Global error handler
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.statusCode || 500;
res.status(status).json({
error: err.message || 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack }),
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
Route Organization and CRUD Operations
Organize routes in separate modules using Express Router. Each resource gets its own file with standard REST endpoints. We will use an in-memory store for simplicity, but the pattern applies identically to any database.
// src/routes/books.js
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
const { validateBook } = require('../middleware/validate');
let books = [
{ id: 1, title: 'Clean Code', author: 'Robert Martin', isbn: '9780132350884', price: 34.99 },
{ id: 2, title: 'The Pragmatic Programmer', author: 'David Thomas', isbn: '9780135957059', price: 44.99 },
];
let nextId = 3;
// GET /api/books -- list all books with optional filtering
router.get('/', (req, res) => {
let result = [...books];
const { author, minPrice, maxPrice, search } = req.query;
if (author) {
result = result.filter(b => b.author.toLowerCase().includes(author.toLowerCase()));
}
if (minPrice) {
result = result.filter(b => b.price >= parseFloat(minPrice));
}
if (maxPrice) {
result = result.filter(b => b.price <= parseFloat(maxPrice));
}
if (search) {
const term = search.toLowerCase();
result = result.filter(b =>
b.title.toLowerCase().includes(term) || b.author.toLowerCase().includes(term)
);
}
res.json({ count: result.length, data: result });
});
// GET /api/books/:id -- get single book
router.get('/:id', (req, res) => {
const book = books.find(b => b.id === parseInt(req.params.id));
if (!book) {
return res.status(404).json({ error: 'Book not found' });
}
res.json({ data: book });
});
// POST /api/books -- create book (auth required)
router.post('/', authenticate, validateBook, (req, res) => {
const { title, author, isbn, price } = req.body;
const book = { id: nextId++, title, author, isbn, price: parseFloat(price) };
books.push(book);
res.status(201).json({ data: book });
});
// PUT /api/books/:id -- update book (auth required)
router.put('/:id', authenticate, validateBook, (req, res) => {
const index = books.findIndex(b => b.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Book not found' });
}
const { title, author, isbn, price } = req.body;
books[index] = { ...books[index], title, author, isbn, price: parseFloat(price) };
res.json({ data: books[index] });
});
// DELETE /api/books/:id -- delete book (auth required)
router.delete('/:id', authenticate, (req, res) => {
const index = books.findIndex(b => b.id === parseInt(req.params.id));
if (index === -1) {
return res.status(404).json({ error: 'Book not found' });
}
books.splice(index, 1);
res.status(204).send();
});
module.exports = router;
Middleware: Validation and Error Handling
Custom middleware functions sit between the request and your route handler. They validate input, check authentication, log activity, and handle errors consistently across all endpoints.
// src/middleware/validate.js
function validateBook(req, res, next) {
const errors = [];
const { title, author, isbn, price } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) {
errors.push('Title is required and must be a non-empty string');
}
if (!author || typeof author !== 'string' || author.trim().length === 0) {
errors.push('Author is required and must be a non-empty string');
}
if (!isbn || !/^\d{10,13}$/.test(isbn.replace(/-/g, ''))) {
errors.push('Valid ISBN (10 or 13 digits) is required');
}
if (price === undefined || isNaN(parseFloat(price)) || parseFloat(price) < 0) {
errors.push('Price must be a non-negative number');
}
if (errors.length > 0) {
return res.status(400).json({ errors });
}
next();
}
module.exports = { validateBook };
For larger applications, consider using a dedicated validation library like Joi or express-validator. They provide declarative schemas and more comprehensive validation rules.
// Alternative: express-validator approach
const { body, validationResult } = require('express-validator');
const bookValidationRules = [
body('title').trim().notEmpty().withMessage('Title is required'),
body('author').trim().notEmpty().withMessage('Author is required'),
body('isbn').matches(/^\d{10,13}$/).withMessage('Valid ISBN required'),
body('price').isFloat({ min: 0 }).withMessage('Price must be non-negative'),
];
function handleValidation(req, res, next) {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
next();
}
JWT Authentication
JSON Web Tokens provide stateless authentication. The server issues a signed token on login, and the client includes it in subsequent requests. Here is a complete auth flow with registration, login, and token verification.
// src/routes/auth.js
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
const JWT_EXPIRES_IN = '24h';
// In-memory user store (use a database in production)
const users = [];
// POST /api/auth/register
router.post('/register', async (req, res) => {
const { username, email, password } = req.body;
if (!username || !email || !password) {
return res.status(400).json({ error: 'All fields are required' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
if (users.find(u => u.email === email)) {
return res.status(409).json({ error: 'Email already registered' });
}
const hashedPassword = await bcrypt.hash(password, 12);
const user = { id: users.length + 1, username, email, password: hashedPassword };
users.push(user);
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
res.status(201).json({
data: { id: user.id, username: user.username, email: user.email },
token,
});
});
// POST /api/auth/login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = users.find(u => u.email === email);
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = jwt.sign({ id: user.id, email: user.email }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
res.json({ token });
});
module.exports = router;
The authentication middleware extracts and verifies the JWT from the Authorization header.
// src/middleware/auth.js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access token required' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(403).json({ error: 'Invalid token' });
}
}
module.exports = { authenticate };
Rate Limiting and Production Hardening
Before deploying, add rate limiting to prevent abuse and ensure your API handles errors gracefully under load.
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
// General API rate limit
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
});
// Stricter limit for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many login attempts, please try again later' },
});
app.use('/api/', apiLimiter);
app.use('/api/auth/', authLimiter);
This bookstore API demonstrates the core patterns of professional Express development: modular routing, layered middleware, input validation, JWT authentication, and production security measures. Every Express API you build will use these same building blocks, regardless of the specific domain or database you choose.