Project Structure for an Express and Mongoose API

A well-organised project structure is not a cosmetic concern โ€” it determines how fast you can add features, how easily a new developer can understand the codebase, and how clearly responsibilities are separated across files. By the time you finish this chapter, your MERN Blog server will be a fully working REST API backed by MongoDB. In this lesson you will design the complete folder structure that will hold all of that code โ€” the models, routes, controllers, middleware, config, and utilities โ€” and understand the responsibility of each layer.

The Complete Server Folder Structure

server/
โ”œโ”€โ”€ index.js                      โ† Entry point: loads env, connects DB, starts HTTP server
โ”œโ”€โ”€ .env                          โ† Secrets (never commit)
โ”œโ”€โ”€ .env.example                  โ† Template with placeholder values (commit this)
โ”œโ”€โ”€ .gitignore
โ”œโ”€โ”€ nodemon.json
โ”œโ”€โ”€ package.json
โ”‚
โ””โ”€โ”€ src/
    โ”œโ”€โ”€ config/
    โ”‚   โ””โ”€โ”€ db.js                 โ† Mongoose connect/disconnect module
    โ”‚
    โ”œโ”€โ”€ models/
    โ”‚   โ”œโ”€โ”€ User.js               โ† User schema + model
    โ”‚   โ”œโ”€โ”€ Post.js               โ† Post schema + model
    โ”‚   โ””โ”€โ”€ Comment.js            โ† Comment schema + model
    โ”‚
    โ”œโ”€โ”€ controllers/
    โ”‚   โ”œโ”€โ”€ authController.js     โ† register, login, logout, getMe, forgotPassword...
    โ”‚   โ”œโ”€โ”€ postController.js     โ† getAllPosts, getPostById, createPost, updatePost, deletePost
    โ”‚   โ”œโ”€โ”€ userController.js     โ† getUserProfile, updateProfile, deleteAccount
    โ”‚   โ””โ”€โ”€ commentController.js  โ† getComments, createComment, updateComment, deleteComment
    โ”‚
    โ”œโ”€โ”€ routes/
    โ”‚   โ”œโ”€โ”€ auth.js               โ† Express Router for /api/auth/*
    โ”‚   โ”œโ”€โ”€ posts.js              โ† Express Router for /api/posts/*
    โ”‚   โ”œโ”€โ”€ users.js              โ† Express Router for /api/users/*
    โ”‚   โ””โ”€โ”€ comments.js           โ† Express Router for /api/posts/:id/comments/*
    โ”‚
    โ”œโ”€โ”€ middleware/
    โ”‚   โ”œโ”€โ”€ auth.js               โ† JWT protect middleware
    โ”‚   โ”œโ”€โ”€ authorize.js          โ† Role-based access control factory
    โ”‚   โ”œโ”€โ”€ validate.js           โ† express-validator error collector
    โ”‚   โ”œโ”€โ”€ validateObjectId.js   โ† MongoDB ObjectId format checker
    โ”‚   โ”œโ”€โ”€ notFound.js           โ† 404 catch-all
    โ”‚   โ””โ”€โ”€ errorHandler.js       โ† Global error handler (4 args)
    โ”‚
    โ”œโ”€โ”€ validators/
    โ”‚   โ”œโ”€โ”€ authValidators.js     โ† register and login validation rules
    โ”‚   โ””โ”€โ”€ postValidators.js     โ† createPost and updatePost validation rules
    โ”‚
    โ””โ”€โ”€ utils/
        โ”œโ”€โ”€ AppError.js           โ† Custom error class with statusCode
        โ”œโ”€โ”€ asyncHandler.js       โ† Async route wrapper for Express 4
        โ””โ”€โ”€ sendEmail.js          โ† Nodemailer email sending utility
Note: This structure follows the MVC (Model-View-Controller) pattern adapted for a REST API. In this context: Models are Mongoose schemas, Views are replaced by JSON responses, and Controllers contain the handler functions. The separation between routes (which URL maps to which controller function) and controllers (what the function does) is the key architectural decision that keeps the code readable and testable.
Tip: Keep your controllers completely free of Express-specific code where possible. A controller function that only reads from req, calls Mongoose methods, and writes to res is easy to unit test โ€” you can call it with a mock req object without needing a running Express server. The less your controller knows about Express routing, the more portable and testable it is.
Warning: Never put business logic directly in route files. Route files should contain only two things: the HTTP method + path, and the list of middleware functions (including the controller) to call. A route file that also contains Mongoose queries, password hashing, or email sending is doing too much โ€” extract that logic into a controller and, if it is reusable, into a utility module.

The Entry Point โ€” index.js

// server/index.js โ€” clean, minimal entry point
require('dotenv').config();

const express    = require('express');
const cors       = require('cors');
const helmet     = require('helmet');
const morgan     = require('morgan');
const rateLimit  = require('express-rate-limit');
const connectDB  = require('./src/config/db');

const app  = express();
const PORT = process.env.PORT || 5000;

// โ”€โ”€ Security and utility middleware โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(helmet());
app.use(cors({
  origin:      process.env.CLIENT_URL || 'http://localhost:5173',
  credentials: true,
}));
if (process.env.NODE_ENV === 'development') app.use(morgan('dev'));
app.use(rateLimit({ windowMs: 15 * 60 * 1000, max: 200 }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: false }));

// โ”€โ”€ API Routes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use('/api/auth',  require('./src/routes/auth'));
app.use('/api/posts', require('./src/routes/posts'));
app.use('/api/users', require('./src/routes/users'));

// โ”€โ”€ Health check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok', env: process.env.NODE_ENV });
});

// โ”€โ”€ Error handling (always last) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
app.use(require('./src/middleware/notFound'));
app.use(require('./src/middleware/errorHandler'));

// โ”€โ”€ Connect DB then start server โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const start = async () => {
  await connectDB();
  app.listen(PORT, () => console.log(`Server โ†’ http://localhost:${PORT}`));
};

start().catch(err => { console.error(err); process.exit(1); });

Responsibility Mapping

Layer File(s) Responsibility Knows About
Config db.js Database connection lifecycle mongoose, process.env
Model Post.js, User.js Schema, validation, hooks, methods mongoose only
Controller postController.js Business logic, DB queries, response Models, AppError, asyncHandler
Route posts.js URL-to-handler mapping, middleware chain express.Router, middleware, controllers
Middleware auth.js, errorHandler.js Cross-cutting concerns โ€” auth, errors JWT, AppError, mongoose error types
Validator postValidators.js Input validation rules express-validator
Utility AppError.js, asyncHandler.js Reusable helpers nothing app-specific

Common Mistakes

Mistake 1 โ€” Putting Mongoose queries directly in route files

โŒ Wrong โ€” routes doing database work:

// routes/posts.js
router.get('/', async (req, res) => {
  const posts = await Post.find({ published: true }).populate('author');
  res.json({ data: posts });
});
// Route file now has business logic โ€” hard to test, hard to reuse

โœ… Correct โ€” route files stay thin; all logic is in controller files:

// routes/posts.js
router.get('/', getAllPosts); // โ† just the mapping

// controllers/postController.js
const getAllPosts = asyncHandler(async (req, res) => {
  const posts = await Post.find({ published: true }).populate('author');
  res.json({ data: posts });
});

Mistake 2 โ€” Importing models in route files instead of controllers

โŒ Wrong โ€” routes importing models directly:

// routes/posts.js
const Post = require('../models/Post'); // model imported into route file
router.get('/', async (req, res) => { const posts = await Post.find()... });

โœ… Correct โ€” only controller files import models:

// controllers/postController.js
const Post = require('../models/Post'); // model belongs in controller โœ“

Mistake 3 โ€” Inconsistent import paths caused by poor folder structure

โŒ Wrong โ€” deep relative paths that break when files are moved:

const AppError = require('../../../utils/AppError'); // fragile relative path

โœ… Correct โ€” keep the structure flat (max 2 levels deep in src/) and consistent:

const AppError = require('../utils/AppError'); // one level up from controllers/ โœ“

Quick Reference โ€” File Creation Checklist

When You Need To Create / Edit
Add a new resource (e.g. comments) models/Comment.js, controllers/commentController.js, routes/comments.js
Add a new route to an existing resource routes/posts.js + controllers/postController.js
Add input validation for a route validators/postValidators.js + routes/posts.js (add validate middleware)
Add a reusable utility utils/yourUtil.js
Add a new middleware middleware/yourMiddleware.js
Add a new environment variable .env (add value) + .env.example (add placeholder)

🧠 Test Yourself

A new developer on your team adds a Mongoose query directly to a route handler in routes/posts.js instead of a controller. Why is this a problem?