I used to handle errors like this:
js
app.get('/api/projects/:id', (req, res) => {
const project = projects.find(p => p.id === Number(req.params.id))
if (!project) {
return res.status(404).json({ message: 'Project not found' })
}
res.json(project)
})
Fine for one route. But when you have 15 routes, that pattern is scattered across your entire codebase. Change the error format? Edit 15 files. Miss one? Inconsistent API responses.
There's a better way.
What is error middleware?
Express has a special type of middleware built specifically for handling errors. The only thing that makes it different from regular middleware is the number of arguments — it takes four instead of three, with err as the first parameter.
js
app.use((err, req, res, next) => {
// all errors in your app land here
})
Express identifies it by that signature. If you write three parameters, Express treats it as regular middleware. Four parameters — it's an error handler. That's it.
How errors reach it
You already know next() — it passes control to the next middleware. When you call next() with something inside it, Express skips every remaining route and middleware and jumps straight to your error handler.
js
app.get('/api/projects/:id', (req, res, next) => {
const project = projects.find(p => p.id === Number(req.params.id))
if (!project) {
next(new Error('Project not found')) // triggers error middleware
}
res.json(project)
})
Whatever you pass into next() becomes the err object your handler receives.
Building a custom error class
The native Error object doesn't carry a status code. So the first thing I built was a custom class:
js
class AppError extends Error {
constructor(message, statusCode) {
super(message)
this.statusCode = statusCode
this.isOperational = true
}
}
module.exports = AppError
isOperational is the important part. It flags errors I deliberately threw — like "project not found" or "missing fields" — as different from unexpected crashes like a database going down. You don't want to expose internal error details to the client. This flag lets you control that.
The centralized handler
js
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500
const message = err.isOperational ? err.message : 'Something went wrong'
res.status(statusCode).json({
status: statusCode,
message: message
})
}).
Two things to note:
err.statusCode || 500 — if the error doesn't have a status code (unexpected crash), default to 500. If you deliberately threw it with new AppError('...', 404), it'll use 404.
err.isOperational — if it's an error you threw on purpose, send the actual message. If it's something unexpected, send a generic message. Your users don't need to see your stack trace.
Important: this has to go after all your routes. Express reads top to bottom. Register it last.
The full project I built
A Velto Projects API with in-memory data, three routes, and one error handler covering everything:
js
require('dotenv').config()
const express = require('express')
const AppError = require('./AppError')
const app = express()
app.use(express.json())
const projects = [
{ id: 1, clientName: 'Velto', status: 'complete', budget: '300k' },
{ id: 2, clientName: 'Nigachu', status: 'complete', budget: '300k' },
{ id: 3, clientName: 'Leona', status: 'complete', budget: '300k' },
]
app.get('/api/projects', (req, res) => {
res.json(projects)
})
app.get('/api/projects/:id', (req, res, next) => {
const project = projects.find(p => p.id === Number(req.params.id))
if (!project) return next(new AppError('Project not found', 404))
res.json(project)
})
app.post('/api/projects', (req, res, next) => {
const { clientName, budget } = req.body
if (!clientName || !budget) {
return next(new AppError('clientName and budget are required', 400))
}
const newProject = {
id: projects.length + 1,
status: 'Ongoing',
clientName,
budget
}
projects.push(newProject)
res.status(201).json(newProject)
})
app.use((err, req, res, next) => {
const statusCode = err.statusCode || 500
const message = err.isOperational ? err.message : 'Something went wrong'
res.status(statusCode).json({ status: statusCode, message })
})
app.listen(process.env.PORT || 3000)
What this actually gives you
Every error in the API — wrong ID, missing fields, future database failures — goes to one place. One function controls the entire error response contract. Change the format once, it changes everywhere.
That's the point. Not just catching errors, but owning how your API fails.
I'm 13 days into learning Express.js from scratch, documenting everything as I go.
Top comments (0)