DEV Community

Yasin Besni
Yasin Besni

Posted on

5 Common Mistakes Beginners Make When Building an Express API

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);
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

Then the logger can use that value:

export function requestLogger(req, res, next) {
  console.log(`${req.method} ${req.originalUrl} [${req.requestId}]`);
  next();
}
Enter fullscreen mode Exit fullscreen mode

If you reverse the order, requestLogger runs before requestId exists.

That means this:

app.use(requestLogger);
app.use(requestContext);
Enter fullscreen mode Exit fullscreen mode

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...
});
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
});
Enter fullscreen mode Exit fullscreen mode

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",
  });
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Then a route can forward the error:

if (!book) {
  return next(new AppError(404, "Book not found."));
}
Enter fullscreen mode Exit fullscreen mode

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,
  });
}
Enter fullscreen mode Exit fullscreen mode

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...");
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

You can still log the real error on the server:

if (statusCode === 500) {
  console.error(err);
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

https://www.amazon.com/dp/B0HL1NWT4B

Top comments (0)