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
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.jsconfigures the Express application. -
server.jsstarts 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}`);
});
Then configure Express separately in app.js:
import express from "express";
const app = express();
app.use(express.json());
export default app;
At this point, the project is still simple.
But there is already a useful separation:
-
server.jsis responsible for starting the server. -
app.jsis 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);
});
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);
};
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;
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;
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.jsconnects 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();
};
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;
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;
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);
};
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);
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
asyncroute 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",
});
};
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;
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
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.jsconfigures the application, -
server.jsstarts 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)