DEV Community

Cover image for How to Structure a Production-Grade Node.js + Express Backend (2026)
Akash Gupta
Akash Gupta

Posted on

How to Structure a Production-Grade Node.js + Express Backend (2026)

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

6. The security basics you can't skip

  • helmet() for sane HTTP headers
  • express-rate-limit on auth routes
  • Hash passwords with bcrypt (never store plaintext)
  • Keep secrets in env, never in the repo (add .env to .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 }))
Enter fullscreen mode Exit fullscreen mode

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)) })
})
Enter fullscreen mode Exit fullscreen mode

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)