DEV Community

Cover image for How to Structure a Node.js + Express Project Without Overcomplicating It
Yasin Besni
Yasin Besni

Posted on

How to Structure a Node.js + Express Project Without Overcomplicating It

A Node.js + Express project can become difficult to navigate surprisingly quickly.

At the beginning, keeping everything in a single file may feel convenient. But as routes, controllers, database logic, middleware, and validation are added, that simple structure becomes harder to maintain.

The solution is not to create dozens of folders from day one.

A better approach is to start with a small structure where every file has a clear responsibility, and expand it only when the project actually needs it.

In this article, we'll build a simple and practical Express project structure without adding unnecessary complexity.

1. Start With a Small and Clear Structure

For a small Express API, a structure like this is usually enough:

src/
├── controllers/
├── routes/
├── middleware/
├── models/
├── app.js
└── server.js
Enter fullscreen mode Exit fullscreen mode

Each part has a specific job:

  • routes/ defines which endpoints exist.
  • controllers/ contains the logic that runs when those endpoints are called.
  • middleware/ contains reusable request-processing logic.
  • models/ contains the application's data models.
  • app.js configures the Express application.
  • server.js starts the HTTP server.

The important idea is not the folder names themselves.

The goal is to avoid putting routing, database operations, validation, and application startup logic into one large file.

For example, instead of writing everything inside server.js, keep the server startup small:

import app from "./app.js";

const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Then configure Express separately in app.js:

import express from "express";

const app = express();

app.use(express.json());

export default app;
Enter fullscreen mode Exit fullscreen mode

At this point, the project is still simple.

But there is already a useful separation:

  • server.js is responsible for starting the server.
  • app.js is responsible for configuring the application.

This small distinction becomes much more valuable as the project grows.

2. Separate Routes From Controllers

As an API grows, route files can easily become crowded with application logic.

Consider a simple /movies endpoint.

You could write everything directly inside the route:

router.get("/", (req, res) => {
  const movies = [
    { id: 1, title: "Movie A" },
    { id: 2, title: "Movie B" },
  ];

  res.json(movies);
});
Enter fullscreen mode Exit fullscreen mode

This works, but the route is now responsible for both defining the endpoint and handling its logic.

A cleaner approach is to move that logic into a controller.

Create a controller:

// controllers/movieController.js

export const getMovies = (req, res) => {
  const movies = [
    { id: 1, title: "Movie A" },
    { id: 2, title: "Movie B" },
  ];

  res.json(movies);
};
Enter fullscreen mode Exit fullscreen mode

Then keep the route focused on routing:

// routes/movieRoutes.js

import express from "express";
import { getMovies } from "../controllers/movieController.js";

const router = express.Router();

router.get("/", getMovies);

export default router;
Enter fullscreen mode Exit fullscreen mode

Finally, register the route in app.js:

import express from "express";
import movieRoutes from "./routes/movieRoutes.js";

const app = express();

app.use(express.json());
app.use("/movies", movieRoutes);

export default app;
Enter fullscreen mode Exit fullscreen mode

Now each file has a clearer responsibility:

  • the route decides which controller should handle the request,
  • the controller handles the request and prepares the response,
  • app.js connects the application's main pieces.

This does not make the application more complicated. It simply prevents unrelated responsibilities from accumulating in the same file.

For a very small project, this separation may feel unnecessary at first. But once an endpoint starts working with validation, database queries, or error handling, having routes and controllers separated becomes much easier to maintain.

3. Use Middleware for Reusable Request Logic

Middleware is useful when the same logic needs to run for multiple requests.

A simple example is request logging.

Instead of adding a console.log() inside every route, create a middleware function:

// middleware/logger.js

export const logger = (req, res, next) => {
  console.log(`${req.method} ${req.url}`);
  next();
};
Enter fullscreen mode Exit fullscreen mode

Then register it in app.js:

import express from "express";
import movieRoutes from "./routes/movieRoutes.js";
import { logger } from "./middleware/logger.js";

const app = express();

app.use(express.json());
app.use(logger);

app.use("/movies", movieRoutes);

export default app;
Enter fullscreen mode Exit fullscreen mode

Now every request passes through the same logging logic.

Middleware is especially useful for concerns such as:

  • authentication,
  • authorization,
  • validation,
  • request logging,
  • error handling.

The main advantage is reuse.

If the same request-related logic appears in multiple routes, that is usually a good sign that it belongs in middleware.

At the same time, not every small piece of logic needs its own middleware file. Creating middleware only when it actually solves duplication keeps the project easier to understand.

4. Keep Data Models Separate

If your application uses a database, keeping model definitions in a separate folder helps prevent database structure from becoming mixed with routing or controller logic.

For example, with Mongoose you might create a simple movie model:

// models/Movie.js

import mongoose from "mongoose";

const movieSchema = new mongoose.Schema({
  title: {
    type: String,
    required: true,
  },
  year: {
    type: Number,
  },
});

const Movie =
  mongoose.models.Movie || mongoose.model("Movie", movieSchema);

export default Movie;
Enter fullscreen mode Exit fullscreen mode

This file is responsible only for describing the structure of movie data and creating the model.

Then the controller can use that model:

// controllers/movieController.js

import Movie from "../models/Movie.js";

export const getMovies = async (req, res) => {
  const movies = await Movie.find();

  res.json(movies);
};
Enter fullscreen mode Exit fullscreen mode

This keeps responsibilities separated:

  • models/ describes the application's data,
  • controllers/ works with that data,
  • routes/ connects URLs to controllers.

The fallback in this line:

mongoose.models.Movie || mongoose.model("Movie", movieSchema);
Enter fullscreen mode Exit fullscreen mode

helps avoid redefining the same model in environments where the module may be loaded more than once.

You do not need a separate model layer for every project.

But once a database is involved, keeping schema definitions away from route files usually makes the code easier to read and maintain.

5. Keep Error Handling Centralized

Note: The examples in this article assume Express 5. In Express 5, rejected promises and errors thrown inside async route handlers are automatically forwarded to the error-handling middleware.

As the project grows, repeating try/catch blocks and response logic in every route or controller can make the code noisy.

A simple error-handling middleware gives the application one place to return unexpected errors.

// middleware/errorHandler.js

export const errorHandler = (err, req, res, next) => {
  console.error(err);

  if (res.headersSent) {
    return next(err);
  }

  res.status(err.status || 500).json({
    message: err.message || "Internal Server Error",
  });
};
Enter fullscreen mode Exit fullscreen mode

Register it after your routes:

import express from "express";
import movieRoutes from "./routes/movieRoutes.js";
import { logger } from "./middleware/logger.js";
import { errorHandler } from "./middleware/errorHandler.js";

const app = express();

app.use(express.json());
app.use(logger);

app.use("/movies", movieRoutes);

app.use(errorHandler);

export default app;
Enter fullscreen mode Exit fullscreen mode

The order matters here.

Express processes middleware from top to bottom, so the error handler should normally be registered after the application routes.

For a small project, this is enough to establish a clear place for unexpected errors without introducing a complex error architecture.

6. What the Final Structure Looks Like

After these small separations, the project might look like this:

src/
├── controllers/
│   └── movieController.js
├── middleware/
│   ├── errorHandler.js
│   └── logger.js
├── models/
│   └── Movie.js
├── routes/
│   └── movieRoutes.js
├── app.js
└── server.js
Enter fullscreen mode Exit fullscreen mode

This is still a small structure.

There are no extra layers just for the sake of architecture.

Each folder exists because it has a clear responsibility:

  • routes/ defines endpoints,
  • controllers/ handles requests,
  • models/ defines application data,
  • middleware/ contains reusable request-related logic,
  • app.js configures the application,
  • server.js starts the server.

7. Add Complexity Only When You Need It

A common mistake is assuming that a professional backend must start with many layers, folders, and abstractions.

It does not.

If the project becomes larger, you may eventually introduce things like service layers, validation schemas, configuration modules, or more advanced error handling.

But those additions should solve a real problem.

A good structure is not the one with the most folders.

It is the one that makes responsibilities easy to find and the application easy to change.

Start small, separate responsibilities when they become meaningful, and let the structure grow with the project.

Final Thoughts

A Node.js + Express project does not need a complicated architecture to be maintainable.

A practical starting point is enough:

  • keep server startup separate from application configuration,
  • keep routes focused on routing,
  • move request logic into controllers when it starts growing,
  • keep database models separate,
  • use middleware for reusable request logic,
  • centralize error handling.

The goal is not to predict every future requirement.

The goal is to make today's code clear without making tomorrow's changes unnecessarily difficult.

Top comments (0)