Express.js Project Structure for Scalable REST APIs

A clean Express.js project structure for REST APIs: routes, controllers, services, validation and central error handling with Express 5 and Node 20.

A good Express.js project structure is the difference between an API you can extend for years and one nobody wants to touch after six months. Express is unopinionated, which is great for small projects and dangerous for big ones. Without structure, route files turn into 800-line monsters that mix SQL, validation, business rules and HTTP responses.

I have refactored several of those, and the same layout fixes them every time. In this guide I will show the folder structure I use for REST APIs on Node 20 and Express 5: routes, thin controllers, services, validation middleware, central error handling, consistent responses, and how this layout makes testing easy.

Prerequisites

  • Node.js 20 LTS or newer
  • Express 5 (npm install express@5)
  • ES modules enabled with "type": "module" in package.json
  • Basic knowledge of middleware and async/await

The Express.js folder structure

Each folder has one job, and dependencies only point in one direction: routes call controllers, controllers call services, services call models. Nothing in services knows about req or res.

src/
  app.js              # express setup, middleware, routes
  server.js           # starts the HTTP server
  config/             # env parsing, constants
  routes/             # URL -> controller mapping only
  controllers/        # read req, call service, send res
  services/           # business logic, no req/res
  models/             # database models / queries
  middleware/         # auth, validation, errors
  validators/         # request schemas
  utils/              # HttpError, helpers
tests/

Separate app.js from server.js

Exporting the app without calling listen lets tests import it and fire requests with Supertest, without opening a real port. server.js becomes the only file that starts the process.

// src/app.js
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import routes from './routes/index.js'
import { notFound, errorHandler } from './middleware/errors.js'

const app = express()

app.use(helmet())
app.use(cors({ origin: process.env.CORS_ORIGIN }))
app.use(express.json({ limit: '1mb' }))

app.use('/api/v1', routes)

app.use(notFound)
app.use(errorHandler) // must be registered last

export default app

// src/server.js
import app from './app.js'

const port = process.env.PORT || 3000
app.listen(port, () => console.log(`API listening on ${port}`))

Routes: mapping URLs only

Route files should read like a table of contents. Version the API in the path from day one; adding /v2 later is painless, renaming every endpoint is not.

// src/routes/index.js
import { Router } from 'express'
import leads from './leads.js'

const router = Router()
router.use('/leads', leads)
export default router

// src/routes/leads.js
import { Router } from 'express'
import * as controller from '../controllers/leads.js'
import { auth } from '../middleware/auth.js'
import { validate } from '../middleware/validate.js'
import { createLeadSchema } from '../validators/leads.js'

const router = Router()

router.get('/', auth, controller.list)
router.get('/:id', auth, controller.show)
router.post('/', auth, validate(createLeadSchema), controller.create)
router.delete('/:id', auth, controller.remove)

export default router

Thin controllers

Controllers translate HTTP into function calls and back. They read params, query and body, call a service, and pick the status code. Business rules live in services so they can be reused by queue jobs, CLI scripts and tests.

In Express 5, a rejected promise in a route handler is passed to next(err) automatically, so there is no need for try/catch blocks or an asyncHandler wrapper in every controller.

// src/controllers/leads.js
import * as leads from '../services/leads.js'

export async function list(req, res) {
  const page = Number(req.query.page) || 1
  const result = await leads.list({ page, ownerId: req.user.id })
  res.json(result)
}

export async function show(req, res) {
  res.json({ data: await leads.findOrFail(req.params.id, req.user.id) })
}

export async function create(req, res) {
  const lead = await leads.create(req.body, req.user)
  res.status(201).json({ data: lead })
}

export async function remove(req, res) {
  await leads.remove(req.params.id, req.user.id)
  res.status(204).end()
}

Services hold the business logic

A service is plain JavaScript. It throws meaningful errors instead of sending responses, which keeps it usable from anywhere.

// src/services/leads.js
import Lead from '../models/lead.js'
import { HttpError } from '../utils/http-error.js'

const PER_PAGE = 20

export async function list({ page, ownerId }) {
  const filter = { owner: ownerId }
  const [data, total] = await Promise.all([
    Lead.find(filter).sort({ createdAt: -1 })
      .skip((page - 1) * PER_PAGE).limit(PER_PAGE).lean(),
    Lead.countDocuments(filter),
  ])
  return { data, meta: { page, perPage: PER_PAGE, total } }
}

export async function findOrFail(id, ownerId) {
  const lead = await Lead.findOne({ _id: id, owner: ownerId }).lean()
  if (!lead) throw new HttpError(404, 'Lead not found')
  return lead
}

export async function create(input, user) {
  const exists = await Lead.exists({ email: input.email, owner: user.id })
  if (exists) throw new HttpError(409, 'Lead already exists')
  return Lead.create({ ...input, owner: user.id })
}

export async function remove(id, ownerId) {
  const { deletedCount } = await Lead.deleteOne({ _id: id, owner: ownerId })
  if (!deletedCount) throw new HttpError(404, 'Lead not found')
}

Request validation middleware

Validate at the edge so services can trust their input. I use Zod because the same schema gives you parsing, defaults and clear error messages. In Express 5, req.query is a read-only getter, so validate the body and params and assign parsed values to a separate property if you need them.

// src/validators/leads.js
import { z } from 'zod'

export const createLeadSchema = z.object({
  email: z.string().email().toLowerCase(),
  name: z.string().min(1).max(120),
  score: z.number().int().min(0).max(100).default(0),
})

// src/middleware/validate.js
export const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body)
  if (!result.success) {
    return res.status(422).json({
      message: 'Validation failed',
      errors: result.error.flatten().fieldErrors,
    })
  }
  req.body = result.data
  next()
}

Central error handling in Express 5

Throw an HttpError with a status code anywhere, and handle it once. Unknown errors are logged with full detail but return a generic message, so stack traces and database errors never leak to clients.

// src/utils/http-error.js
export class HttpError extends Error {
  constructor(status, message, details) {
    super(message)
    this.status = status
    this.details = details
  }
}

// src/middleware/errors.js
export function notFound(req, res) {
  res.status(404).json({ message: `Route ${req.method} ${req.path} not found` })
}

export function errorHandler(err, req, res, next) {
  const status = err.status || 500
  if (status >= 500) console.error(err)
  res.status(status).json({
    message: status >= 500 ? 'Server error' : err.message,
    ...(err.details && { errors: err.details }),
  })
}

Consistent REST API responses

Pick one response shape and stick to it. Front-end developers should never have to guess whether a list is at the top level or inside data.

  • 200 for reads and updates, 201 with the created resource for creates, 204 with no body for deletes
  • 400 for malformed requests, 401 when not logged in, 403 when logged in but not allowed, 404 when missing, 409 for conflicts, 422 for validation errors
  • Wrap payloads in data and put pagination in meta
  • Errors always have a message, and validation errors add an errors object keyed by field
  • Use plural nouns for resources (/leads, /leads/:id) and let the HTTP method express the action

Testing the structure

Because app.js does not listen, Supertest can hit real routes in memory. Services can be unit-tested without HTTP at all, which is where most of your tests should live.

// tests/leads.test.js
import request from 'supertest'
import app from '../src/app.js'

test('rejects invalid email', async () => {
  const res = await request(app)
    .post('/api/v1/leads')
    .set('Authorization', 'Bearer test-token')
    .send({ email: 'nope', name: 'Ann' })

  expect(res.status).toBe(422)
  expect(res.body.errors.email).toBeDefined()
})

Common mistakes and fixes

  • Error handler never runs: it was registered before the routes, or it does not have four arguments. Express identifies error middleware by its signature.
  • Unhandled promise rejections in Express 4: upgrade to Express 5, or wrap async handlers, because Express 4 does not forward rejections.
  • Path pattern errors after upgrading to Express 5: wildcards must be named, so use /*splat instead of /*, and optional segments use braces.
  • Business logic duplicated between routes and jobs: move it into a service and call it from both.
  • Huge request bodies crashing the server: set a limit on express.json.

Security best practices

  • Use helmet for secure headers and restrict CORS to known origins
  • Rate-limit authentication routes with express-rate-limit
  • Always scope queries by the current user to prevent insecure direct object references
  • Load secrets from environment variables and validate them at startup
  • Never return raw error messages or stack traces for 500 responses

Conclusion

A scalable Express.js project structure is not about a particular folder name. It is about keeping each layer focused: routes map URLs, controllers speak HTTP, services hold the rules, and one error handler decides what clients see. With Express 5 handling async errors for you, this layout stays small and readable, and it scales from a weekend project to a team codebase without a rewrite.

Frequently asked questions

What is the best folder structure for an Express.js REST API?

A layered structure with routes, controllers, services, models and middleware works for most APIs. Group by feature folders instead once you have many modules, but keep the same separation inside each feature.

Does Express 5 handle async errors automatically?

Yes. In Express 5, if an async route handler or middleware throws or returns a rejected promise, the error is passed to next automatically. You no longer need express-async-errors or wrapper functions.

What is the difference between a controller and a service in Express?

A controller deals with HTTP: it reads the request and sends the response. A service contains business logic and knows nothing about Express, so it can be reused from jobs, scripts and tests.

Should I use Express or NestJS for a new Node.js API?

NestJS enforces structure through modules and decorators, which helps large teams. Express with a clear layout like this one is lighter and easier to learn, and it is my choice for small and medium APIs.