REST isn’t about endpoints. It’s about resources, and most tutorials get this wrong. They’ll show you app.get('/getBooks') and app.post('/createBook') and call it RESTful because Express is involved. But sticking HTTP verbs into URL paths defeats the entire architectural principle Roy Fielding described in his 2000 dissertation. A resource — /books, /books/42 — exists independently of what you want to do with it. How you interact with that resource comes from the HTTP method, not the URL.
I learned this the hard way sometime around late 2021, refactoring a Node.js API that had grown to 80+ routes, all named like RPC calls. /fetchUserOrders, /updateUserAddress, /deletePaymentMethod. Renaming wasn’t cosmetic — it changed how we thought about the whole system. Resources became first-class citizens. Middleware could target patterns. Documentation shrank by half because the naming was self-describing.
So here’s what we’re going to build: a bookstore API. Not a toy. Something with proper routing, layered middleware, input validation, JWT authentication, rate limiting — the full production stack. You can probably skip the sections you already know. But if you’ve been stitching together Express tutorials that each cover one slice without showing how the pieces connect, stick around. We’ll assemble the whole machine.
Bootstrapping the Project
Start with a fresh directory and pull in the packages we’ll need. Express handles routing and middleware orchestration. Helmet injects security headers automatically. CORS manages cross-origin access. Morgan logs every HTTP request in a structured format. And the auth packages — jsonwebtoken and bcryptjs — handle token generation and password hashing respectively.
mkdir bookstore-api && cd bookstore-api
npm init -y
npm install express cors helmet morgan dotenv jsonwebtoken bcryptjs
npm install -D nodemon
Nodemon goes into dev dependencies since it’s purely a development convenience — restarts your server on file changes so you aren’t killing and relaunching the process manually every thirty seconds. If you haven’t used it before, add "dev": "nodemon src/app.js" to your package.json scripts and run npm run dev. Some teams have switched to --watch mode baked into Node.js 18+, which does roughly the same thing without an extra dependency. Either works. Pick one and move on — this isn’t a decision worth agonizing over.
Now for the entry point. Every Express API I’ve built in the past three years follows roughly this same skeleton: instantiate the app, layer on middleware, mount route modules, then add error handlers at the bottom. Order matters here more than people realize. Express processes middleware in the order you register it, so security headers (Helmet) and body parsing need to come before your routes, and error handlers must come last.
// 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}`));
A few things worth noting. That { limit: '10kb' } on express.json() caps how large a request body can be. Without it, someone could POST a 500MB JSON blob and crash your process. CORS is configured to read allowed origins from an environment variable, falling back to wildcard — fine for development, dangerous for production. And the global error handler conditionally includes the stack trace only in development mode. You don’t want stack traces leaking to clients in production; they’re a goldmine for attackers.
Notice the health check endpoint sits outside the /api prefix. Load balancers and container orchestrators like Kubernetes hit that route to determine whether your service is alive. Keeping it lightweight — no database calls, no auth — means it responds even when downstream dependencies are struggling.
One more thing about this setup that catches people off guard in 2026: the global error handler takes four arguments — (err, req, res, next). That four-parameter signature is how Express distinguishes error-handling middleware from regular middleware. Drop any one of those parameters and Express won’t recognize it as an error handler. I’ve debugged this exact issue twice on Stack Overflow questions where someone’s errors were silently disappearing because they wrote (err, req, res) with three arguments. Express was treating it as a normal middleware and skipping it during error propagation.
Routes, Resources, and CRUD
Back to our bookstore. A book is a resource. We need five operations: list all books, get one book, create a book, update a book, delete a book. In REST, those map cleanly to HTTP methods on two URL patterns:
GET /api/books— collectionGET /api/books/:id— single resourcePOST /api/books— createPUT /api/books/:id— updateDELETE /api/books/:id— remove
Express Router lets us define all of these in an isolated module. Each route file becomes a mini-application with its own middleware stack. For this tutorial, we’re using an in-memory array rather than a database — keeps the focus on Express patterns rather than MongoDB driver syntax or Sequelize ORM quirks. Swap in your persistence layer later; the routing structure won’t change.
// 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;
Read operations — the two GETs — are public. Anyone can browse the catalog. But creating, updating, and deleting books require authentication. We haven’t built the auth middleware yet; that’s coming. For now, notice the pattern: router.post('/', authenticate, validateBook, handler). Middleware functions chain left to right. If authenticate fails, validateBook never runs. If validateBook fails, the handler never runs. Each layer is a gate, and the request only reaches the final handler if every gate opens.
Filtering on the GET collection endpoint deserves a mention too. Query parameters — ?author=Martin&minPrice=20 — let clients narrow results without dedicated search endpoints. Some teams reach for POST-based search routes with JSON bodies when queries get complex, and honestly that’s fine for advanced cases. For a bookstore catalog though, query strings handle it well enough.
Something I want to flag about the DELETE response: we’re returning 204 No Content, which means no response body. An empty response. Some frontend developers find this irritating because they want confirmation of what got deleted. You could return 200 with the deleted resource in the body instead — REST doesn’t mandate one over the other. My preference is 204 because the client already knows what it asked to delete. Sending it back feels redundant. But if your frontend team insists, 200 with the deleted object is perfectly valid REST.
Also worth noting: parseInt(req.params.id) is doing quiet work throughout these handlers. Express params arrive as strings, always. Forgetting to parse them — comparing "3" === 3 and getting false — is a bug I see in almost every beginner’s first Express API. TypeScript largely eliminates this class of error, which is one reason so many Node.js teams migrated to it between 2022 and 2024.
Middleware: Where Express Earns Its Keep
Middleware is probably the single concept that separates Express from a raw Node.js HTTP server. Without it, you’d be writing the same validation checks, the same auth checks, the same error formatting inside every single route handler. Middleware lets you extract that repeated logic into composable functions that sit in the request pipeline.
Let’s build the validation layer for our book resource. Each field gets checked, errors accumulate into an array, and if anything fails, we short-circuit with a 400 response before the route handler ever sees the request.
// 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 };
Hand-written validation works for small APIs. Once you hit maybe ten or fifteen resources, though, it starts to feel like you’re writing the same conditionals over and over with slightly different field names. That’s when libraries like Joi or express-validator become worth the dependency. They let you declare validation rules as schemas rather than imperative if-else chains.
// 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();
}
Declarative wins here for the same reason declarative usually wins: you describe what valid input looks like rather than how to check it. Someone reading the express-validator version grasps the requirements in three seconds. Someone reading the manual version has to trace each conditional path to understand the same thing. Both approaches produce identical behavior, so it’s really a matter of team preference and scale.
JWT Authentication — Stateless, Portable, Dangerous If Mishandled
JSON Web Tokens solve a specific problem: how does a server verify that a client is who they claim to be without storing session state? A JWT encodes user identity (and whatever claims you want) into a signed, base64-encoded string. The server signs the token on login, hands it to the client, and the client sends it back with every subsequent request in the Authorization header. No session table. No Redis store. Just cryptographic verification.
Sounds elegant, and it is — until you consider the tradeoffs. You can’t easily revoke a JWT before it expires. If someone steals a token, they’ve got access until that expiration timestamp passes. Token blacklists exist as a workaround, but they reintroduce server-side state, which kind of defeats the purpose. For most CRUD APIs that aren’t handling banking transactions, a short expiry (24 hours, maybe less) with refresh token rotation is probably good enough.
Here’s the complete auth flow. Registration hashes the password with bcrypt at a cost factor of 12, stores the user, and returns a signed token. Login compares the supplied password against the stored hash and issues a fresh token on success.
// 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;
One detail that’s easy to miss: both the “user not found” and “wrong password” cases return the same 401 error with the same message — “Invalid credentials.” Never tell an attacker which part failed. If the error said “User not found” for a bad email and “Wrong password” for a bad password, an attacker could enumerate valid email addresses by watching which error they get. Security through vagueness isn’t enough on its own, but leaking information actively makes things worse.
Now the middleware that protects routes. It pulls the token from the Authorization: Bearer <token> header, verifies the signature against our secret, and attaches the decoded payload to req.user so downstream handlers can identify who’s making the request.
// 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 };
Expired tokens get a 401 (unauthorized — re-authenticate). Tampered or malformed tokens get a 403 (forbidden — your credentials are invalid, not just missing). Subtle distinction, but it helps clients handle the two cases differently. A 401 might trigger a refresh flow; a 403 probably means log the user out entirely.
A quick aside on the JWT secret. In the code above, it falls back to a hardcoded string if the environment variable isn’t set. That fallback exists so the tutorial runs out of the box. In production, you’d set JWT_SECRET to a long random string (64+ characters) stored in your environment or a secrets manager like AWS Secrets Manager or HashiCorp Vault. A colleague of mine shipped a side project in early 2023 with the default secret still in place. Someone decoded his JWTs, forged admin tokens, and had full write access to his API for two weeks before he noticed. Don’t be that person. Make the secret long, random, and stored outside your codebase.
Rate Limiting Before Someone Hammers Your Server
Any API exposed to the internet will get hit with automated traffic. Bots probing for vulnerabilities. Scripts scraping data. Credential stuffing attacks trying thousands of password combinations against your login endpoint. Rate limiting won’t stop a determined attacker, but it raises the cost and buys you time to notice and respond.
npm install express-rate-limit
Two limiters make sense for our bookstore: a general one for the API as a whole, and a stricter one specifically for authentication endpoints. A hundred requests per fifteen minutes is generous for normal browsing. Ten login attempts in the same window? That should be plenty for a human who mistyped their password, and far too few for a brute-force script to accomplish anything useful.
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);
The standardHeaders: true setting includes RateLimit-* headers in every response, so clients can see how many requests they’ve got left in the current window. Setting legacyHeaders: false disables the older X-RateLimit-* format. Small detail, but modern clients expect the standard headers and legacy ones just add noise.
Worth mentioning: express-rate-limit uses an in-memory store by default, which means rate limits reset whenever your server restarts. In production, especially behind multiple instances, you’d swap in a Redis-backed store so the counters persist and are shared across all your server processes. The API for doing that is straightforward — just pass a store option — but it’s beyond the scope of what we’re building here.
Putting It All Together — And What I’d Change for Production
If you’ve followed along, you’ve got a bookstore API with five working endpoints, input validation, JWT-based authentication, and rate limiting. That covers maybe 80% of what every REST API needs. But there’s a gap between “works on localhost” and “ready for real traffic” that I want to be honest about.
First, the in-memory data store. Obviously that won’t survive a restart. PostgreSQL or MongoDB are the usual choices, and either one plugs in cleanly — your route handlers barely change since the logic stays the same, just the data access layer gets swapped. I’d probably reach for Prisma or Knex.js as the query layer rather than writing raw SQL strings, but that’s a whole separate tutorial.
Second, error handling could be more sophisticated. Right now we’ve got a catch-all global handler that logs to console and returns a JSON error. In production, you’d want structured logging (Winston or Pino), error tracking (Sentry), and maybe different error response formats for different client types. An error class hierarchy — NotFoundError, ValidationError, AuthenticationError — each with its own status code, lets the global handler be much cleaner than a single switch statement.
Third, testing. We haven’t written a single test, and in a real codebase that’d be irresponsible. Supertest pairs beautifully with Jest or Mocha for Express API testing — it lets you make HTTP requests against your app instance without actually starting the server. Each test file spins up the app, fires requests, asserts on status codes and response bodies, and tears down. If you’re working on a team, an untested API is a liability that’ll slow everyone down once the codebase grows past a few hundred lines.
And fourth — something I see overlooked a lot — API versioning. Our routes are under /api/books. What happens six months from now when you need to change the response format? If clients depend on the current structure, you can’t just break them. Versioning from day one (/api/v1/books) gives you a clean escape hatch. Add /api/v2/books with the new format, deprecate v1 on a timeline, and nobody’s integration breaks overnight.
Back to the Bookstore
Remember where we started? REST isn’t about endpoints. It’s about resources. Our bookstore API embodies that principle: /api/books is the resource, and the HTTP methods — GET, POST, PUT, DELETE — describe what you’re doing with it. No /getBooks. No /createBook. No verbs in URLs at all. The verbs live in the HTTP protocol where they belong.
I think about that 80-route API I refactored back in 2021 sometimes. If the original developer had started with Express Router modules, middleware layers, and RESTful naming from the beginning, we’d never have needed the rewrite. Most of the code would’ve been identical — Express doesn’t force you into bad patterns, it just doesn’t stop you either. That’s simultaneously its greatest strength and its biggest trap.
So take these patterns. Build something. When the bookstore gets boring, swap in your own domain — recipes, invoices, IoT sensor readings, whatever. The middleware chains, the auth flow, the validation layer, the error handling — all of it transfers directly. Express gives you building blocks, not a framework that decides how your application should look. That freedom means the architecture is your responsibility, and now you’ve got a solid blueprint to start from.
What you build on top of it? That’s the interesting part.