You have a Node.js server that's growing, and your index.js file has turned into a 500-line monster. Routes overlap, middleware conflicts, and when a 500 error hits, you discover it because the client sends a browser screenshot. Sounds familiar?
We, at Meteora Web, have built dozens of backends with Express — and we've seen this exact scenario. The problem isn't Express, which remains excellent. It's the lack of a thought-out structure before writing the first route. Routing, middleware, and error handling are not details: they are the backbone architecture of your backend. Treat them as such, and your code stays maintainable, testable, and — most importantly — you won't wake up at 3 AM because of a production bug.
This guide starts from a concrete project. No abstract theory: folder structure, routing patterns, custom middleware, centralized error handling, and a system that works from the first line.
Why Project Structure Is Not Optional
When you start an Express project, temptation is one: npm init, install express, and dump everything into app.js. It works while you have three routes. Then you have thirty, and you realize chaos is not a bug: it's a design that never existed.
As former bookkeepers turned engineers, we think in terms of costs: every hour spent refactoring is a cost you could have avoided. A solid structure is an investment that pays off immediately.
The Structure We Use in Real Projects
project-root/
├── src/
│ ├── app.js # Express configuration
│ ├── server.js # Server start
│ ├── routes/ # Route definitions
│ │ ├── index.js # Main router
│ │ ├── auth.routes.js
│ │ └── users.routes.js
│ ├── controllers/ # Route logic
│ │ ├── auth.controller.js
│ │ └── users.controller.js
│ ├── middleware/ # Custom middleware
│ │ ├── errorHandler.js
│ │ ├── auth.middleware.js
│ │ └── validate.middleware.js
│ ├── models/ # Data schemas (Mongoose, Sequelize, etc.)
│ ├── services/ # Business logic (optional)
│ ├── utils/ # Helper functions
│ └── config/ # Configurations (env, db, etc.)
├── tests/
├── .env
└── package.json
What changes compared to the monolith? Every file has a single responsibility. routes/ only defines which URLs exist. controllers/ contains response logic. Middleware is separate and reusable. No more authentication functions copied in ten places.
Immediate action: Create this structure in your Express project. Even if it's empty, the setup forces you to think in modules.
Advanced Routing: Organize Routes Like a Pro
Express provides express.Router(). It's not optional: it's the proper way to segment routes. Using the router lets you mount sub-paths, add group-specific middleware, and keep the main file clean.
Practical Example: User Routes
// src/routes/users.routes.js
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/users.controller');
const authMiddleware = require('../middleware/auth.middleware');
const validateMiddleware = require('../middleware/validate.middleware');
router.get('/', usersController.getAll);
router.get('/:id', usersController.getById);
router.post('/', validateMiddleware.validateCreateUser, usersController.create);
router.put('/:id', authMiddleware.requireAdmin, usersController.update);
router.delete('/:id', authMiddleware.requireAdmin, usersController.delete);
module.exports = router;
Then in the main router:
// src/routes/index.js
const express = require('express');
const router = express.Router();
const authRoutes = require('./auth.routes');
const usersRoutes = require('./users.routes');
router.use('/auth', authRoutes);
router.use('/users', usersRoutes);
module.exports = router;
Finally, in app.js mount everything with one line:
app.use('/api/v1', require('./routes'));
Benefit: if tomorrow you need to change from /api/v1/ to /api/v2/, you modify only that line. No need to touch any route.
Parameters and Query: Clean Handling
Never validate parameters inside the controller. Use dedicated validation middleware. Example with a simple helper:
// src/middleware/validate.middleware.js
function validateCreateUser(req, res, next) {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'Name and email are required' });
}
// Optional: more robust validation (Joi, express-validator)
next();
}
module.exports = { validateCreateUser };
Immediate action: Extract every validation into a separate middleware. Your controller stays clean and you can test validation independently.
Middleware: The Chain That Decides Everything
A middleware in Express is a function that receives req, res, next. It can modify the request, terminate the response, or pass control to the next. Getting the order of middleware wrong is the second most common mistake (after putting them all in one file).
Correct Middleware Order
- Global middleware (body-parser, cors, logging) — before anything else
- Authentication middleware — if the route requires it
- Validation middleware — before calling the controller
- Controller — the final logic
- Error handler — at the bottom, to catch everything
// src/app.js
const express = require('express');
const app = express();
// 1. Global middleware
app.use(express.json());
app.use(cors());
app.use(morgan('dev'));
// 2. Health check route (no auth)
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// 3. All other routes
app.use('/api/v1', require('./routes'));
// 4. Error handler (must be last)
app.use(require('./middleware/errorHandler'));
module.exports = app;
Middleware with Parameters (Factory)
Sometimes you need a configurable middleware. Example: role-based access with multiple levels:
// src/middleware/requireRole.js
function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Forbidden' });
}
next();
};
}
module.exports = requireRole;
Usage: router.delete('/:id', requireRole('admin', 'moderator'), controller.delete);
Immediate action: Review your existing middleware. If any have duplicated logic, turn them into reusable factory middleware.
Centralized Error Handling: Don't Discover Errors Too Late
Express's standard error pattern is try/catch in every controller and call next(err). But if you miss a single catch, the app crashes and the server dies. The solution is a central error handler and an async wrapper.
Async/Await Wrapper
// src/utils/asyncHandler.js
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
Usage in controller:
const asyncHandler = require('../utils/asyncHandler');
exports.getAll = asyncHandler(async (req, res) => {
const users = await User.find();
res.json(users);
});
Now any async error automatically goes to the error handler.
Custom Error Handler
// src/middleware/errorHandler.js
function errorHandler(err, req, res, next) {
// Log the error (in production use a proper logger)
console.error(err.stack);
// Custom error with status code
const statusCode = err.statusCode || 500;
const message = err.message || 'Internal Server Error';
res.status(statusCode).json({
success: false,
error: message,
// In dev, include stack
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
}
module.exports = errorHandler;
And a custom HTTP error class:
// src/utils/HttpError.js
class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
module.exports = HttpError;
Usage: throw new HttpError(404, 'User not found');
Immediate action: Add the error handler to your project. If you don't have an async wrapper, add one. Then convert all controllers to use try { ... } catch(next) or directly asyncHandler.
Production Readiness: The Final Mile
It's not enough that the backend works locally. You need to ensure the designed structure holds up under load and doesn't leak internals.
- Environment: use
dotenvand separate configs for dev/staging/prod. - Logging: replace
console.logwith a structured logger (winston, pino). - Rate limiting: protect public APIs with
express-rate-limit. - Helmet: add security headers (
helmet). - CORS: configure only the domains that need access.
We, at Meteora Web, have seen projects where the error handler returned full stack traces in production. Result: an attacker knew the Express version and library paths. Don't do that. Our example error handler already separates dev from prod.
In Summary: What to Do Now
- Restructure the project using the
routes/controllers/middlewarepattern. Even a partial refactor helps. - Move all routes into separate routers and mount them with
express.Router(). - Centralize error handling: create
errorHandler.jsand an async wrapper as shown. - Validate every input with dedicated middleware, not inside controllers.
- Test your error handler: simulate an error and verify it returns clean JSON with no stack in production.
A backend with these characteristics is not just cleaner: it's safer, more performant, and much cheaper to maintain over time. And we see it every day on our clients' projects.
For deeper reading, check the official Express error handling guide.
Sponsored Protocol