Building your first Express API is usually straightforward.
You install Express, create a server, add a few routes, and suddenly you have a working backend.
The harder part comes later.
As the project grows, small decisions that seemed harmless at the beginning can make the code harder to understand, test, and maintain.
Here are five common mistakes beginners make when building Express APIs, along with practical ways to avoid them.
1. Putting Everything in server.js
A small Express application often starts like this:
import express from "express";
const app = express();
app.get("/users", (req, res) => {
// fetch users
});
app.post("/users", (req, res) => {
// create user
});
app.listen(3000);
There is nothing wrong with this when you are learning.
The problem begins when the same file starts containing:
- routes
- validation
- database queries
- authentication
- error handling
- business logic
At that point, server.js stops being an entry point and becomes the entire application.
A better direction is to gradually separate responsibilities.
For example:
src/
├── server.js
├── routes/
├── controllers/
├── middleware/
└── models/
You do not need a complex architecture on day one.
The goal is simply to keep unrelated responsibilities from becoming tightly coupled.
2. Ignoring Middleware Order
In Express, middleware runs in the order in which it is registered.
That sounds simple, but it is one of the easiest things to overlook.
Consider this example:
app.use(requestContext);
app.use(requestLogger);
The first middleware creates information that the second middleware can use.
For example:
import { randomUUID } from "node:crypto";
export function requestContext(req, res, next) {
req.requestId = randomUUID();
next();
}
Then the logger can use that value:
export function requestLogger(req, res, next) {
console.log(`${req.method} ${req.originalUrl} [${req.requestId}]`);
next();
}
If you reverse the order, requestLogger runs before requestId exists.
That means this:
app.use(requestLogger);
app.use(requestContext);
does not behave the same way.
Middleware order is part of your application logic.
Do not treat app.use() calls as interchangeable.
3. Repeating Validation Inside Every Route
Another common pattern is validating request data directly inside each route:
app.post("/api/messages", (req, res) => {
if (!req.body.message) {
return res.status(400).json({
message: "Message is required",
});
}
// continue...
});
This works.
But if several routes need similar checks, validation logic quickly becomes repetitive.
A simple middleware can make the responsibility clearer:
export function validateMessage(req, res, next) {
const { message } = req.body;
if (typeof message !== "string" || message.trim() === "") {
return res.status(400).json({
message: "The message field must be a non-empty string.",
});
}
req.body.message = message.trim();
next();
}
Now the route can focus on what it actually needs to do:
app.post("/api/messages", validateMessage, (req, res) => {
res.status(201).json({
message: req.body.message,
});
});
This is one of the main benefits of middleware:
reusable request-processing logic without duplicating it across routes.
4. Handling Every Error Inside the Route
Beginners often write error responses directly inside every route:
if (!book) {
return res.status(404).json({
message: "Book not found",
});
}
Again, this is not automatically wrong.
But when the application grows, you may repeat the same patterns dozens of times.
A centralized error flow gives you one place to control how API errors are returned.
A small custom error class can help:
export class AppError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
}
}
Then a route can forward the error:
if (!book) {
return next(new AppError(404, "Book not found."));
}
And centralized middleware handles the response:
export function errorHandler(err, req, res, next) {
if (res.headersSent) {
return next(err);
}
const statusCode =
Number.isInteger(err.statusCode) && err.statusCode >= 400
? err.statusCode
: 500;
const message =
statusCode === 500 && !err.isOperational
? "Internal server error"
: err.message;
if (statusCode === 500) {
console.error(err);
}
res.status(statusCode).json({
status: "error",
message,
});
}
The route becomes easier to read, and the API gets a more consistent error format.
5. Sending Internal Error Details to the Client
This is a more serious mistake.
Imagine something unexpected happens:
throw new Error("Database connection failed at...");
Returning the entire internal error to the client can expose information the user does not need to see.
For unexpected server errors, a safer response is usually something generic:
{
"status": "error",
"message": "Internal server error"
}
You can still log the real error on the server:
if (statusCode === 500) {
console.error(err);
}
This creates an important separation:
- developers get the information needed for debugging
- API clients receive a safe and predictable response
Expected application errors, such as validation failures or missing resources, can still return useful messages.
Unexpected internal failures should generally stay internal.
Keep the Architecture Proportional to the Project
There is another mistake worth avoiding: overengineering.
A beginner project does not need ten architectural layers just because large production systems use them.
Start simple.
Then separate responsibilities when the code gives you a reason to do so.
A useful progression might look like this:
single Express file
↓
routes + controllers
↓
middleware
↓
centralized error handling
↓
database models
↓
larger application structure
Each step should solve a real problem.
Architecture is useful when it makes the code easier to understand and change — not when it only adds more folders.
Companion Code
I created a public companion repository with runnable examples for the concepts discussed here:
https://github.com/besniapp-blip/nodejs-backend-development-book
The repository currently includes examples for:
- Express basics
- middleware flow
- request validation
- request logging
- centralized error handling
I will continue adding practical backend examples over time.
Further Reading
These examples also accompany my book:
Node.js Backend Development: A Practical Guide to Building Production-Ready APIs
The book follows the same practical approach, starting with backend fundamentals and gradually building toward structured Node.js and Express applications.
Amazon:
Top comments (0)