Where validation belongs
Three chapters of schemas, all run by hand on objects written a few lines above them. Real input arrives over the network, and a schema is worth something only where that input lands.
The question is where to put the check, and the answer changes how the rest of the code is written.
The path a submission takes
A registration form posts to the server. Between the request arriving and your code running, one thing happens:
form ──POST──▶ validation ──▶ route handler ──▶ response
│
└──▶ 400 with the errorsValidation sits in the middle, and it decides which of two endings the request gets.
Fails. The schema finds problems, the request stops there, and a 400 Bad Request goes back carrying a list of what was wrong. The route handler never runs.
Passes. The parsed data continues to the route handler, which does the real work and answers with 201 Created.
The handler only ever receives data that already matched the schema.
Nothing gets to the room behind it without going through, so the room can stop asking whether things were checked.
Why the check goes in front
The alternative is to validate at the top of each route handler, which works and doesn't hold.
// The version that drifts
import { registerSchema } from './schemas/userSchema.js'
app.post('/api/register', (req, res) => {
const result = registerSchema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({ success: false, errors: result.error.issues })
}
// ...the actual work, eventually
})Every route repeats that block. Repeated code drifts: one route maps the errors and another returns them raw, one forgets the return and carries on into the handler after answering, a new route copies whichever version was nearest.
Putting the check in front makes it one piece of code every route shares, and makes a route's validation readable from its definition instead of its body.
One copy can be fixed once. Twelve copies means finding out which of them has the bug.
What the handler receives
A handler behind validation gets to be short, because it can assume the shape it was given:
import { validate } from './middleware/validate.js'
import { registerSchema } from './schemas/userSchema.js'
app.post('/api/register', validate(registerSchema), (req, res) => {
const userData = req.validatedData
// Business logic, working from data that already matched the schema.
res.status(201).json({ success: true, user: { email: userData.email } })
})Three things sit between the path and the handler now: validate(registerSchema) is the check, and the handler runs only if it passed.
The handler reads req.validatedData, not req.body. That distinction is the whole arrangement. req.body is what arrived; req.validatedData is what survived, with any transformations applied.
req.body is whatever was sent. req.validatedData is what passed the check. Read the second one and the handler never has to wonder.
Try it
Here's a route that validates inside the handler:
import * as z from 'zod'
import { saveSubscriber } from './services/subscribers.js'
const subscribeSchema = z.object({
email: z.email('Enter a valid email address'),
})
app.post('/api/subscribe', (req, res) => {
const result = subscribeSchema.safeParse(req.body)
if (!result.success) {
res.status(400).json({ success: false, errors: result.error.issues })
}
const { email } = req.body
saveSubscriber(email)
res.status(201).json({ success: true })
})Find three problems with it.
Compare your answers
1. The missing return. res.status(400).json(...) sends a response and keeps executing, so an invalid request gets a 400 and reaches saveSubscriber. The client sees a rejection while the subscriber is saved.
2. It reads req.body instead of result.data. Even with the return fixed, the value used is the raw one, so any trimming or lowercasing in the schema is discarded and anything the schema stripped is back.
3. It returns result.error.issues unchanged. Zod's issue objects carry internal detail, including the expected type and the failing constraint, and they're shaped for code. A client needs field-and-message pairs.
There's a fourth thing that isn't a bug in this route and becomes one at scale: all of this lives inside the handler, so the next route gets a copy, and the copies drift.
Where this goes next
The plan is settled. A check in front of the handler, a 400 with usable errors on failure, parsed data attached to the request on success.
Validation middleware builds it: one function that takes any schema and returns something Express can put in front of any route.

