Why Your Express.js Folder Structure Matters More Than You Think
If you have ever opened an Express.js project and found every route, controller, and utility crammed into a single file or a flat directory, you know the pain. It works when you have five endpoints. It becomes a nightmare at fifty.
The best folder structure for an Express.js project is not about following a rigid template. It is about choosing a layout that keeps your codebase readable, testable, and easy to scale as your team and feature set grow. This guide gives you an opinionated, production-ready structure you can adopt today and evolve for years.
We wrote this in 2026 because the ecosystem has matured. ESM is the default, TypeScript adoption is mainstream, and patterns like layered architecture and domain colocation are no longer experimental. Let’s put it all together.
The Complete Express.js Folder Structure at a Glance
Below is the structure we recommend for medium-to-large Express.js applications. After the overview, we will break down every folder and file so you understand the reasoning behind each decision.
project-root/
├── src/
│ ├── config/
│ │ ├── index.js
│ │ ├── database.js
│ │ └── logger.js
│ ├── controllers/
│ │ ├── auth.controller.js
│ │ └── user.controller.js
│ ├── services/
│ │ ├── auth.service.js
│ │ └── user.service.js
│ ├── models/
│ │ ├── user.model.js
│ │ └── token.model.js
│ ├── routes/
│ │ ├── index.js
│ │ ├── auth.routes.js
│ │ └── user.routes.js
│ ├── middleware/
│ │ ├── errorHandler.js
│ │ ├── auth.js
│ │ └── validate.js
│ ├── utils/
│ │ ├── apiError.js
│ │ └── helpers.js
│ ├── validators/
│ │ ├── auth.validator.js
│ │ └── user.validator.js
│ ├── jobs/
│ │ └── cleanupTokens.job.js
│ ├── app.js
│ └── server.js
├── tests/
│ ├── unit/
│ └── integration/
├── .env
├── .env.example
├── package.json
└── README.md
This layout follows a layered architecture pattern. Each layer has a single responsibility, and dependencies flow in one direction: routes call controllers, controllers call services, services call models.
Breaking Down Every Folder and Its Role
src/config/ – Centralized Configuration
This folder holds all environment-specific settings. Instead of scattering process.env calls across your codebase, you import a single config object.
- index.js – Reads
.envvariables, validates them (consider using a library likeenvalidorzod), and exports a frozen config object. - database.js – Database connection logic (MongoDB, PostgreSQL, etc.).
- logger.js – Logger setup (Winston, Pino, or similar).
Why it matters: A single source of truth for configuration prevents bugs caused by typos in environment variable names and makes it trivial to swap values between development, staging, and production.
src/routes/ – URL Mapping Layer
Routes should be thin. Their only job is to map HTTP methods and paths to the correct controller function and attach any relevant middleware.
// src/routes/user.routes.js
import { Router } from 'express';
import { getUser, updateUser } from '../controllers/user.controller.js';
import { authenticate } from '../middleware/auth.js';
import { validateUpdateUser } from '../validators/user.validator.js';
const router = Router();
router.get('/:id', authenticate, getUser);
router.patch('/:id', authenticate, validateUpdateUser, updateUser);
export default router;
The routes/index.js file acts as a barrel file, mounting all sub-routers under their base path:
// src/routes/index.js
import { Router } from 'express';
import authRoutes from './auth.routes.js';
import userRoutes from './user.routes.js';
const router = Router();
router.use('/auth', authRoutes);
router.use('/users', userRoutes);
export default router;
src/controllers/ – Request and Response Handling
A controller receives the incoming request, extracts the data it needs, passes that data to a service, and sends back the response. It should not contain business logic or direct database calls.
// src/controllers/user.controller.js
import * as userService from '../services/user.service.js';
export async function getUser(req, res, next) {
try {
const user = await userService.getUserById(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
}
Rule of thumb: If you see a database query or a complex conditional inside a controller, move it to the service layer.
src/services/ – Business Logic Layer
This is where the real work happens. Services contain the business rules, orchestrate calls to one or more models, and handle data transformation.
- Services are framework-agnostic. They do not know about
reqorres. - This makes them easy to unit test without mocking Express internals.
- A service can call other services when you need cross-domain logic.
src/models/ – Data Layer
Models define your database schemas and expose query methods. Whether you use Mongoose, Sequelize, Prisma, Drizzle, or raw SQL, keep schema definitions here.
src/middleware/ – Reusable Request Pipeline Functions
Middleware intercepts requests before they reach a controller or responses before they leave. Common middleware includes:
| File | Purpose |
|---|---|
| auth.js | Verifies JWTs or session tokens and attaches user data to the request |
| validate.js | Generic validation middleware factory (wraps Zod, Joi, etc.) |
| errorHandler.js | Global error handler that formats error responses consistently |
| rateLimiter.js | Throttles requests to prevent abuse |
src/validators/ – Input Validation Schemas
Keep validation schemas separate from middleware and controllers. This keeps each file focused and lets you reuse schemas in tests or scripts.
src/utils/ – Shared Helpers
Small, pure functions that do not belong to any specific domain: custom error classes, date formatters, slug generators, pagination helpers, and so on.
src/jobs/ – Background and Scheduled Tasks
If your project runs cron jobs or queue workers (using BullMQ, Agenda, or node-cron), give them a dedicated folder. Each file exports a job definition.
src/app.js vs. src/server.js – Separating App from Server
This separation is small but powerful:
- app.js creates and configures the Express application (middleware, routes, error handler) and exports it.
- server.js imports the app, connects to the database, and calls
app.listen().
This lets your integration tests import the app without actually starting the server.
tests/ – Test Suite
Mirror the src/ structure inside tests/unit/ and tests/integration/. This makes it obvious which source file each test covers.
Layered Architecture: How the Layers Connect
The flow for a typical request looks like this:
- Route matches the URL and HTTP method.
- Middleware runs (authentication, validation, rate limiting).
- Controller extracts input from the request and calls the appropriate service.
- Service executes business logic, calling models as needed.
- Model interacts with the database and returns data.
- Controller formats the response and sends it back to the client.
- Error handler middleware catches any errors that were passed via
next(err).
This one-directional dependency chain is the key to maintainability. A model never imports a controller. A service never touches res.json().
When to Use Domain-Based (Colocation) Structure Instead
The layered structure above works well for most projects. However, once your application has many distinct domains (users, products, orders, payments, notifications), you might prefer colocation, where each domain gets its own folder containing its routes, controller, service, model, and validators:
src/
├── modules/
│ ├── users/
│ │ ├── users.routes.js
│ │ ├── users.controller.js
│ │ ├── users.service.js
│ │ ├── users.model.js
│ │ └── users.validator.js
│ ├── orders/
│ │ ├── orders.routes.js
│ │ ├── orders.controller.js
│ │ ├── orders.service.js
│ │ ├── orders.model.js
│ │ └── orders.validator.js
├── middleware/
├── config/
├── utils/
├── app.js
└── server.js
This approach is inspired by how frameworks like NestJS organize code. The benefit is that everything related to a feature lives in one place, making it easier to navigate in large codebases.
Layered vs. Colocated: Quick Comparison
| Criteria | Layered (by role) | Colocated (by domain) |
|---|---|---|
| Best for | Small to medium projects | Large projects with many domains |
| Navigation | Easy to find all controllers, all services, etc. | Easy to find everything about a single feature |
| Refactoring | Changing a layer affects multiple folders | Changes are mostly isolated to one folder |
| Shared code | Natural (middleware, utils at top level) | Needs a shared/ or common/ folder |
Our recommendation: start with the layered structure. If you cross roughly 8 to 10 distinct resource domains and find yourself constantly jumping between folders, migrate to the colocated approach. The service and controller patterns remain identical; only the folder layout changes.
10 Best Practices for Express.js Project Structure in 2026
- Keep
app.jsandserver.jsseparate. This enables cleaner testing and graceful shutdown handling. - Never put business logic in controllers. Controllers are thin glue between HTTP and your services.
- Validate input at the edge. Use a validators folder and run validation middleware before the controller executes.
- Use a centralized error handler. A single
errorHandlermiddleware at the end of your middleware chain ensures consistent error responses. - Centralize config and validate it on startup. If a required environment variable is missing, the app should fail fast with a clear message.
- Use path aliases or barrel files. Avoid deeply nested relative imports like
../../../config. Use Node.js subpath imports or TypeScript path aliases. - Adopt a consistent naming convention. We recommend
resource.layer.js(e.g.,user.controller.js,user.service.js). - Mirror your src/ structure in tests/. Finding the test for any file should never require a search.
- Use ESM (import/export). CommonJS still works, but ESM is the standard in 2026 and plays better with modern tooling.
- Document decisions in a README or ADR folder. A folder structure is only useful if the team knows why it exists and agrees to follow it.
A Real-World Example: User Registration Flow
Let’s trace a user registration request through the structure to see how all the pieces connect.
1. Route
// src/routes/auth.routes.js
router.post('/register', validateRegistration, register);
2. Validator
// src/validators/auth.validator.js
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
const registrationSchema = z.object({
body: z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1),
}),
});
export const validateRegistration = validate(registrationSchema);
3. Controller
// src/controllers/auth.controller.js
import * as authService from '../services/auth.service.js';
export async function register(req, res, next) {
try {
const user = await authService.registerUser(req.body);
res.status(201).json({ user });
} catch (err) {
next(err);
}
}
4. Service
// src/services/auth.service.js
import User from '../models/user.model.js';
import { ApiError } from '../utils/apiError.js';
import { hashPassword } from '../utils/helpers.js';
export async function registerUser({ email, password, name }) {
const existing = await User.findOne({ email });
if (existing) {
throw new ApiError(409, 'Email already registered');
}
const hashed = await hashPassword(password);
const user = await User.create({ email, password: hashed, name });
return { id: user.id, email: user.email, name: user.name };
}
5. Model
// src/models/user.model.js (Mongoose example)
import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
name: { type: String, required: true },
}, { timestamps: true });
export default mongoose.model('User', userSchema);
Notice how each file has a single, clear responsibility. You can unit test the service without spinning up Express. You can swap Mongoose for Prisma without touching the controller. That is the power of a well-organized folder structure.
Common Mistakes to Avoid
- Putting everything in
index.jsfiles. When every folder has anindex.js, debugging stack traces becomes confusing. Use descriptive filenames. - Creating folders you don’t need yet. Start with the folders your current features require. Add
jobs/orevents/when you actually need them. - Mixing concerns in middleware. Authentication middleware should not also handle authorization. Keep them as separate, composable functions.
- Skipping the config layer. Accessing
process.env.DB_HOSTin 15 different files is a recipe for hard-to-trace bugs. - Ignoring TypeScript. Even if you prefer JavaScript, consider using JSDoc type annotations at minimum. In 2026, type safety is not optional for production projects.
Scaling the Structure for Larger Teams
When multiple developers or squads work on the same Express.js project, consider these additions:
- docs/ folder for API documentation (OpenAPI specs, ADRs).
- scripts/ folder for database seeds, migrations, and deployment helpers.
- types/ folder (if using TypeScript) for shared type definitions and interfaces.
- events/ or listeners/ folder if you use an event-driven pattern internally.
- Monorepo tooling (Turborepo, Nx) if you split the project into packages (e.g.,
@app/api,@app/shared,@app/jobs).
Frequently Asked Questions
What is the best folder structure for an Express.js project?
The best folder structure separates concerns into distinct layers: routes for URL mapping, controllers for request handling, services for business logic, models for data access, middleware for cross-cutting concerns, and config for environment settings. This layered approach keeps each file focused and makes the codebase easy to test and scale.
Should I organize Express.js files by feature or by layer?
For small to medium projects, organizing by layer (controllers/, services/, models/) is simple and effective. For larger applications with many distinct domains, organizing by feature (modules/users/, modules/orders/) keeps related code together and reduces context switching. You can also combine both approaches.
Where should I put middleware in an Express.js project?
Create a dedicated src/middleware/ folder. Place each middleware function in its own file with a descriptive name. Attach middleware either globally in app.js or at the route level in your route files.
Is MVC still a good pattern for Express.js in 2026?
MVC works, but most production Express.js applications benefit from adding a service layer between controllers and models. This extra layer (sometimes called MVCS) prevents controllers from becoming bloated and makes business logic reusable and testable independently of HTTP.
How do I handle environment variables in a structured Express.js project?
Create a src/config/index.js file that reads all process.env variables, validates them using a schema library, and exports a single configuration object. Every other file in the project imports config from this one location.
Can I use this folder structure with TypeScript?
Absolutely. The structure is identical. Simply use .ts extensions, add a tsconfig.json at the project root, and optionally create a src/types/ folder for shared interfaces and type definitions.
Final Thoughts
There is no single “correct” folder structure for every Express.js project. But there are patterns that have proven themselves across thousands of production applications. The layered architecture we outlined here gives you clean separation of concerns, straightforward testing, and a clear path for growth.
Start with the structure that matches your current project size. Follow the naming conventions consistently. Separate your app from your server. Keep controllers thin and services fat. Do these things, and you will spend less time fighting your codebase and more time shipping features.
At Box Software, we build and architect Node.js applications for teams that care about long-term code quality. If you need help structuring a new project or refactoring an existing one, get in touch.
