How to Structure a Production-Grade Node.js + Express Backend (2026)
Most Node.js tutorials stop at app.get('/', ...). Then you land a real project, the codebase hits 40 files, and everything lives in one 800-line index.js. Been there.
After building and reviewing dozens of backends, here's the structure and the handful of decisions that actually keep a Node + Express project maintainable — explained so a beginner can follow, but detailed enough to use at work today.
1. Layer your app: route → controller → service
The single biggest upgrade you can make is to stop putting logic inside routes.
- Route — only wiring. Which URL maps to which handler.
- Controller — reads the request, calls a service, sends the response.
- Service — the actual business logic (talks to the DB, other APIs).
// routes/user.routes.js
import { Router } from 'express'
import * as userController from '../controllers/user.controller.js'
const router = Router()
router.get('/:id', userController.getUser)
export default router
// controllers/user.controller.js
import * as userService from '../services/user.service.js'
export const getUser = async (req, res, next) => {
try {
const user = await userService.findById(req.params.id)
if (!user) return res.status(404).json({ message: 'User not found' })
res.json(user)
} catch (err) { next(err) }
}
// services/user.service.js
import User from '../models/User.js'
export const findById = (id) => User.findById(id).lean()
Why it matters: your business logic becomes testable without HTTP, and swapping Express for Fastify later touches only the route/controller layer.
2. A folder structure that scales
src/
config/ # env, db connection, third-party clients
models/ # schemas
routes/ # URL wiring only
controllers/ # request/response glue
services/ # business logic
middleware/ # auth, validation, error handler
utils/ # pure helpers
app.js # express app (no listen)
server.js # starts the server
Keep app.js (build the app) separate from server.js (start it). Your tests can import app without opening a port.
3. Centralize config — never read process.env everywhere
// config/env.js
import 'dotenv/config'
export const env = {
port: process.env.PORT || 3000,
mongoUri: process.env.MONGO_URI,
jwtSecret: process.env.JWT_SECRET,
nodeEnv: process.env.NODE_ENV || 'development',
}
if (!env.mongoUri) throw new Error('MONGO_URI is required')
Reading env in one place means a missing variable fails loudly at startup, not silently at 2 AM in production.
4. One error handler to rule them all
Stop writing try/catch that just res.status(500). Funnel everything to a single error middleware.
// middleware/error.js
export const errorHandler = (err, req, res, next) => {
const status = err.status || 500
if (status === 500) console.error(err) // log real bugs
res.status(status).json({ message: err.message || 'Something went wrong' })
}
// app.js (must be registered LAST)
app.use(errorHandler)
Controllers just next(err) and move on. Clean and consistent.
5. Validate input at the edge
Never trust the request body. Validate before it reaches your service (Zod is great):
import { z } from 'zod'
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
})
export const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body)
if (!result.success) return res.status(400).json({ errors: result.error.issues })
req.body = result.data
next()
}
6. The security basics you can't skip
-
helmet()for sane HTTP headers -
express-rate-limiton auth routes - Hash passwords with
bcrypt(never store plaintext) - Keep secrets in env, never in the repo (add
.envto.gitignore) - Validate + sanitize every input (point 5)
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
app.use(helmet())
app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 20 }))
7. Graceful shutdown (the detail nobody teaches)
When your host restarts the app, finish in-flight requests and close the DB cleanly:
const server = app.listen(env.port)
process.on('SIGTERM', () => {
server.close(() => { mongoose.connection.close(false, () => process.exit(0)) })
})
Putting it together
A good backend isn't about knowing every library — it's about separation of concerns, failing loudly, and never trusting input. Master these seven and you're already ahead of most juniors.
If you want a structured path from "first API" to "deployed, production-ready backend" — with Node.js, Express, MongoDB, MySQL and Redis — that's exactly what we teach at AS Backend Institute.
What does your Express folder structure look like? Drop it in the comments 👇
Written by the team at AS Backend Institute — a placement-focused backend development course for aspiring developers in India.
Top comments (0)