TL;DR
- Express stays relevant because it is flexible, boring, and easy to compose around real production needs.
- The hard part is not route registration; it is middleware boundaries, validation, auth, idempotency, and failure handling.
- I keep business rules out of ad hoc middleware chains and I make request contracts explicit early.
- Good Express APIs feel small at the edge and disciplined underneath.
Context
I have used Node.js and Express-style services in environments where the request path was not the only concern. The real production questions were:
- how do we validate and reject bad input early?
- where do auth and tenant context get attached?
- how do we keep async errors predictable?
- how do we make retries safe for writes?
- how do we keep routers modular without building a maze?
That is where API quality is won or lost.
The Approach
1. Treat middleware as boundary plumbing, not a dumping ground
Good middleware responsibilities:
- request parsing
- auth/session extraction
- logging and correlation ids
- validation
- rate limiting
- response shaping
Bad middleware responsibilities:
- hidden business decisions
- cross-route orchestration that should live in services
- mutating random request shape without a contract
import express from 'express'
import { z } from 'zod'
const app = express()
app.use(express.json({ limit: '1mb' }))
const createOrderSchema = z.object({
sku: z.string().min(1),
quantity: z.number().int().positive(),
})
function validateBody<T>(schema: z.ZodSchema<T>) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ error: 'Invalid request', issues: result.error.flatten() })
}
req.body = result.data
next()
}
}
app.post('/orders', validateBody(createOrderSchema), async (req, res, next) => {
try {
const order = await createOrder(req.body)
res.status(201).json(order)
} catch (error) {
next(error)
}
})
2. Make write retries safe with idempotency
This matters for:
- checkout
- payments
- order placement
- provisioning APIs
- anything users may submit twice after a timeout
app.post('/orders', async (req, res, next) => {
try {
const key = req.header('Idempotency-Key')
if (!key) {
return res.status(400).json({ error: 'Missing Idempotency-Key header' })
}
const existing = await idempotencyStore.get(key)
if (existing) {
return res.status(existing.statusCode).json(existing.body)
}
const order = await createOrder(req.body)
const responseBody = { id: order.id, status: 'created' }
await idempotencyStore.set(key, {
statusCode: 201,
body: responseBody,
})
return res.status(201).json(responseBody)
} catch (error) {
next(error)
}
})
3. Centralize async error handling
Express still rewards discipline here.
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message
res.status(500).json({ error: message })
})
I want route handlers to be readable, not each one inventing its own error envelope.
4. Keep routers modular and service logic separate
const ordersRouter = express.Router()
ordersRouter.get('/:orderId', async (req, res, next) => {
try {
const order = await orderService.findById(req.params.orderId)
if (!order) return res.status(404).json({ error: 'Not found' })
res.json(order)
} catch (error) {
next(error)
}
})
app.use('/orders', ordersRouter)
The router owns the HTTP contract. The service owns business rules.
5. Add observability as part of the contract
At minimum, I want:
- request id
- route + method
- latency
- status code
- principal or tenant where safe
Because API regressions become expensive fast when the team cannot see where latency or error rates cluster.
Trade-offs
- Express is highly flexible, but that flexibility makes architecture discipline your responsibility.
- Middleware chains are powerful, but too many implicit side effects make APIs hard to reason about.
- The ecosystem is mature, but large teams can accumulate inconsistency unless they standardize validation, error envelopes, and observability.
- Newer frameworks may provide stronger built-in types or Web-standard primitives, but Express still wins on familiarity and ecosystem gravity in many teams.