Skip to content

Error handling ​

Scattered try/catch with five different JSON shapes is how APIs become annoying for FE. One error middleware, one envelope.

The four-argument middleware ​

ts
// must be registered AFTER routes
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  console.error(err)

  const status = err instanceof HttpError ? err.status : 500
  const message = err instanceof HttpError ? err.message : 'server error'

  res.status(status).json({ error: message })
})

Express detects error middleware by the 4 parameters. Skip one and it won't work the way you expect. 🤔

A tiny HttpError helper ​

ts
export class HttpError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message)
  }
}

// usage in a route
throw new HttpError(404, 'note not found')

Then async routes:

ts
notesRouter.get('/:id', async (req, res, next) => {
  try {
    const note = await findNote(req.params.id)
    if (!note) throw new HttpError(404, 'note not found')
    res.json({ note })
  } catch (err) {
    next(err)
  }
})

Async wrapper (less boilerplate) ​

ts
const asyncHandler =
  (fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) =>
  (req: Request, res: Response, next: NextFunction) => {
    Promise.resolve(fn(req, res, next)).catch(next)
  }

notesRouter.get(
  '/:id',
  asyncHandler(async (req, res) => {
    const note = await findNote(req.params.id)
    if (!note) throw new HttpError(404, 'note not found')
    res.json({ note })
  }),
)

As you can see, catch(next) is the whole trick.

What the SPA should expect ​

Pick a stable error shape and stick to it:

json
{ "error": "email already registered" }

Optional later: { error, code, details } for field validation. Consistency > cleverness.

Don't leak stack traces to clients ​

Log err server-side. Send a short message to the browser. In production, generic 500 text is fine; detailed messages for 4xx are usually OK.

404 fallthrough ​

ts
app.use('/api', (_req, res) => {
  res.status(404).json({ error: 'not found' })
})

app.use(errorMiddleware)

Order: routers → API 404 → error middleware.

Next: env files, secrets, and CORS so your Vite app can actually call this thing.

Best,

-- ł.

Notes-style Express tutorial for front-end folks