Express.js JSON REST API: Middleware, Validation, and Error Handling
Building a JSON REST API with Express.js requires three foundational pieces: express.json() middleware to parse request bodies, a consistent response envelope for success and error formats, and proper HTTP status codes aligned with RFC 9457. express.json() accepts bodies up to 100 KB by default — increase with { limit: '10mb' } for large payloads; the Content-Type: application/json header must be set on POST/PATCH requests or the body parser silently returns undefined. This guide covers setting up Express JSON middleware, structuring response helpers, centralized error handling middleware, input validation with Zod, CORS configuration for cross-origin JSON requests, and a complete CRUD endpoint example. For the response format conventions referenced throughout, see REST API JSON response format.
Need to validate or prettify a JSON payload before testing your API? Jsonic's JSON formatter checks syntax instantly.
Open JSON FormatterSetting up express.json() middleware
express.json() is a built-in body parser that reads the raw HTTP request body, verifies Content-Type: application/json, and populates req.body with the parsed JavaScript object. It ships with Express 4.16+ — no separate body-parser package required. Register it with app.use() before any route that reads req.body; middleware runs in the order it is declared, so routes defined before express.json() will always see req.body === undefined. The default body size limit is 100 KB; clients sending larger payloads receive a 413 Payload Too Large error automatically. Two options cover 95% of real-world configuration needs:
import express from 'express'
const app = express()
// Basic setup — parses application/json bodies up to 100 KB
app.use(express.json())
// Increased limit for file-metadata or bulk APIs
app.use(express.json({ limit: '10mb' }))
// Strict mode false: also parses primitive JSON values (strings, numbers)
// Default strict: true only accepts objects {} and arrays []
app.use(express.json({ strict: false }))
// Custom Content-Type — useful for application/merge-patch+json routes
app.use(express.json({ type: 'application/merge-patch+json' }))
app.post('/users', (req, res) => {
// req.body is the parsed object — e.g. { name: 'Alice', email: 'alice@example.com' }
console.log(req.body)
res.status(201).json({ data: req.body })
})A common mistake is sending a POST request without the Content-Type: application/json header. The middleware inspects this header before parsing — if it is missing or set to application/x-www-form-urlencoded, express.json() skips the body entirely and req.body remains undefined. This produces no error from Express itself, so validation errors appear to be about missing fields when the real problem is the missing header. Always set the header in your client; see fetch JSON in JavaScript for the correct fetch() options.
If the client sends malformed JSON — a missing comma, trailing comma, or unquoted key — Express catches the SyntaxError thrown by JSON.parse() and passes it to the error middleware chain with status 400. You do not need to wrap req.body access in a try/catch for parse errors; they are handled before your route handler runs.
Structuring a consistent JSON response envelope
A consistent response envelope makes it easier for API clients to parse responses programmatically without inspecting HTTP status codes directly. Wrap every response in a data field for success and a structured error object for failures. The following helpers take 5 lines each and eliminate scattered res.json() calls with inconsistent shapes across 20+ routes. For a deeper discussion of response format conventions, see REST API JSON response format.
// helpers/response.ts
import { Response } from 'express'
export function sendSuccess<T>(
res: Response,
data: T,
status = 200,
meta?: Record<string, unknown>
) {
return res.status(status).json({ data, ...(meta ? { meta } : {}) })
}
export function sendError(
res: Response,
status: number,
title: string,
detail: string,
errors?: Record<string, string[]>
) {
return res.status(status).json({
type: `https://jsonic.io/errors/${title.toLowerCase().replace(/ /g, '-')}`,
title,
status,
detail,
...(errors ? { errors } : {}),
})
}
// Usage in a route
app.get('/users/:id', async (req, res) => {
const user = await db.users.findById(req.params.id)
if (!user) return sendError(res, 404, 'Not Found', `User ${req.params.id} does not exist`)
return sendSuccess(res, user)
})The error shape follows RFC 9457 (Problem Details for HTTP APIs), which defines 5 standard fields: type (a URI identifying the error class), title (a short human-readable summary), status (the HTTP status code as a number), detail (a human-readable explanation specific to this occurrence), and instance (a URI reference to the specific request, optional). The errors extension field carries per-field validation messages that clients can map to form inputs. Using res.json() throughout — never res.send(string) — guarantees the Content-Type: application/json; charset=utf-8 header is set on every response, including errors.
Input validation with Zod
Zod is the most widely-used TypeScript-first schema validation library for Express APIs: it infers TypeScript types from the schema definition so the validated req.body is fully typed with 0 extra type annotations. Install it with npm install zod. The key method is schema.safeParse() — unlike schema.parse(), it never throws; it returns { success: true, data: T } or { success: false, error: ZodError }, making it safe to call in route handlers without try/catch. Return HTTP 422 for schema validation failures and HTTP 400 for completely missing bodies. For a deeper look at JSON Schema validation, see validate JSON Schema in JavaScript.
import { z } from 'zod'
import express from 'express'
const app = express()
app.use(express.json())
// Define the schema — inferred TypeScript type is automatic
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
})
type CreateUserInput = z.infer<typeof CreateUserSchema>
// Reusable validation middleware factory
function validate<T>(schema: z.ZodType<T>) {
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
if (!req.body || typeof req.body !== 'object') {
return res.status(400).json({
title: 'Bad Request',
status: 400,
detail: 'Request body must be a JSON object',
})
}
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(422).json({
title: 'Validation Error',
status: 422,
detail: 'One or more fields failed validation',
errors: result.error.flatten().fieldErrors,
})
}
// Attach typed, validated data to req for route handler
;(req as any).validatedBody = result.data
return next()
}
}
app.post('/users', validate(CreateUserSchema), (req, res) => {
const body = (req as any).validatedBody as CreateUserInput
// body.name, body.email are guaranteed correct types
res.status(201).json({ data: body })
})The result.error.flatten().fieldErrors call converts Zod's internal error tree into a flat object like { "email": ["Invalid email"], "name": ["String must contain at least 1 character(s)"] }. This maps directly to the errors field in the RFC 9457 response envelope, and front-end frameworks like React Hook Form can consume the format directly to display per-field error messages without additional parsing.
Centralized error handling middleware
Express identifies error-handling middleware by the exact arity of 4 parameters: (err, req, res, next). If you accidentally define the function with 3 parameters, Express treats it as a regular middleware and errors silently bypass it — they fall through to Express's default error handler, which sends an HTML error page instead of JSON. Register the error middleware after all routes and other app.use() calls. The pattern below handles 3 error categories: Zod validation errors (422), Express body-parser syntax errors (400), and unexpected server errors (500).
import { ZodError } from 'zod'
// Custom error class for operational errors
class AppError extends Error {
constructor(
public status: number,
public title: string,
public detail: string,
public errors?: Record<string, string[]>
) {
super(detail)
this.name = 'AppError'
}
}
// Centralized error middleware — MUST have exactly 4 parameters
app.use((
err: unknown,
req: express.Request,
res: express.Response,
next: express.NextFunction // required even if unused
) => {
// 1. Zod validation error thrown manually
if (err instanceof ZodError) {
return res.status(422).json({
title: 'Validation Error',
status: 422,
detail: 'One or more fields failed validation',
errors: err.flatten().fieldErrors,
})
}
// 2. Express body-parser SyntaxError (malformed JSON)
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({
title: 'Bad Request',
status: 400,
detail: 'Request body contains invalid JSON',
})
}
// 3. Operational application error
if (err instanceof AppError) {
return res.status(err.status).json({
title: err.title,
status: err.status,
detail: err.detail,
...(err.errors ? { errors: err.errors } : {}),
})
}
// 4. Unexpected server error — never leak stack traces in production
console.error(err)
return res.status(500).json({
title: 'Internal Server Error',
status: 500,
detail: process.env.NODE_ENV === 'development'
? String(err)
: 'An unexpected error occurred',
})
})Throw an AppError anywhere in a route handler or middleware and call next(err) to route it to the centralized handler. For async route handlers in Express 4, either wrap them in a try/catch that calls next(err), or install express-async-errors which patches Express to automatically forward rejected promises to the error middleware. Express 5 (currently in release candidate) handles async errors natively.
CORS configuration for JSON APIs
Cross-Origin Resource Sharing (CORS) is a browser security mechanism that blocks JavaScript from reading responses from a different origin unless the server explicitly allows it. For JSON APIs consumed by web browsers, CORS must be configured correctly or all fetch requests from your front end will fail. The most important detail for JSON APIs: the Access-Control-Allow-Headers: Content-Type response header is required for any POST or PATCH request that sends a JSON body, because Content-Type: application/json is not a "simple" header in the CORS specification and triggers a preflight OPTIONS request. Install the cors package with npm install cors.
import cors from 'cors'
import express from 'express'
const app = express()
// 1. Permissive — allows all origins (fine for public read-only APIs)
app.use(cors())
// 2. Allowlist — recommended for authenticated APIs
app.use(cors({
origin: ['https://app.example.com', 'https://staging.example.com'],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['X-Total-Count'], // headers the browser JS can read
credentials: true, // allow cookies / Authorization header
maxAge: 86400, // cache preflight for 24 hours (seconds)
}))
// 3. Dynamic origin (regex or function)
app.use(cors({
origin: (origin, callback) => {
const allowed = /^https://(.*.)?example.com$/.test(origin ?? '')
callback(null, allowed)
},
}))
// Register cors() BEFORE express.json() and routes
app.use(express.json())
app.get('/items', (req, res) => res.json({ data: [] }))CORS preflight works as follows: the browser sends an HTTP OPTIONS request with Access-Control-Request-Headers: Content-Type. The server must respond with Access-Control-Allow-Headers: Content-Type and a 2xx status. If the server returns 404 or omits the header, the browser blocks the actual request and shows a CORS error in the console — even if the underlying API endpoint is working perfectly. The cors package handles OPTIONS responses automatically when registered with app.use(). Set maxAge to a large value (86400 = 24 hours) to cache the preflight result in the browser and avoid an extra round-trip on every JSON POST.
Complete CRUD endpoint example
The following example combines all the pieces — express.json(), Zod validation, the response envelope, and the error middleware — into a complete CRUD router for a /users resource. It implements 5 endpoints: GET (list), POST (create), GET by ID, PATCH (update), and DELETE. Use JSON.stringify to debug any serialization issue before sending to the client.
// routes/users.ts
import express from 'express'
import { z } from 'zod'
export const usersRouter = express.Router()
// --- Schemas ---
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user'),
})
const UpdateUserSchema = CreateUserSchema.partial() // all fields optional for PATCH
// --- In-memory store (replace with DB) ---
let users: Array<{ id: number } & z.infer<typeof CreateUserSchema>> = []
let nextId = 1
// GET /users — list all with optional ?role filter
usersRouter.get('/', (req, res) => {
const { role } = req.query
const result = role
? users.filter(u => u.role === role)
: users
res.json({ data: result, meta: { total: result.length } })
})
// POST /users — create
usersRouter.post('/', (req, res, next) => {
const parsed = CreateUserSchema.safeParse(req.body)
if (!parsed.success) {
return res.status(422).json({
title: 'Validation Error',
status: 422,
detail: 'Request body failed validation',
errors: parsed.error.flatten().fieldErrors,
})
}
const user = { id: nextId++, ...parsed.data }
users.push(user)
res.status(201)
.header('Location', `/users/${user.id}`)
.json({ data: user })
})
// GET /users/:id — fetch one
usersRouter.get('/:id', (req, res) => {
const user = users.find(u => u.id === Number(req.params.id))
if (!user) return res.status(404).json({ title: 'Not Found', status: 404, detail: `User ${req.params.id} not found` })
res.json({ data: user })
})
// PATCH /users/:id — partial update
usersRouter.patch('/:id', (req, res) => {
const idx = users.findIndex(u => u.id === Number(req.params.id))
if (idx === -1) return res.status(404).json({ title: 'Not Found', status: 404, detail: `User ${req.params.id} not found` })
const parsed = UpdateUserSchema.safeParse(req.body)
if (!parsed.success) {
return res.status(422).json({
title: 'Validation Error',
status: 422,
detail: 'Request body failed validation',
errors: parsed.error.flatten().fieldErrors,
})
}
users[idx] = { ...users[idx], ...parsed.data }
res.json({ data: users[idx] })
})
// DELETE /users/:id — remove
usersRouter.delete('/:id', (req, res) => {
const idx = users.findIndex(u => u.id === Number(req.params.id))
if (idx === -1) return res.status(404).json({ title: 'Not Found', status: 404, detail: `User ${req.params.id} not found` })
users.splice(idx, 1)
res.status(204).send()
})// app.ts — wire it all together
import express from 'express'
import cors from 'cors'
import { usersRouter } from './routes/users'
const app = express()
app.use(cors({ origin: 'https://app.example.com', allowedHeaders: ['Content-Type', 'Authorization'] }))
app.use(express.json({ limit: '1mb' }))
app.use('/users', usersRouter)
// Centralized error handler (4-parameter signature)
app.use((err: unknown, req: express.Request, res: express.Response, _next: express.NextFunction) => {
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({ title: 'Bad Request', status: 400, detail: 'Invalid JSON in request body' })
}
console.error(err)
res.status(500).json({ title: 'Internal Server Error', status: 500, detail: 'Unexpected error' })
})
app.listen(3000, () => console.log('API listening on http://localhost:3000'))Test the API with curl: curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"name":"Alice","email":"alice@example.com"}'. Omitting the -H "Content-Type: application/json" flag returns req.body === undefined and triggers a 422 from Zod — a clear demonstration of why the header is mandatory. Use Jsonic's JSON formatter to validate response payloads during development.
HTTP status codes for a JSON REST API
Correct status codes are the primary signal clients use to determine how to handle a response programmatically. Using 200 for errors, or 500 for validation failures, forces clients to parse the body before knowing whether the call succeeded — breaking the contract of the HTTP protocol. The table below covers the 12 most important codes for a CRUD JSON API, with the exact Express pattern for each.
| Code | Name | When to use | Express pattern |
|---|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH | res.json(data) |
| 201 | Created | Successful POST creating a resource | res.status(201).header('Location', url).json(data) |
| 204 | No Content | Successful DELETE with no body | res.status(204).send() |
| 400 | Bad Request | Malformed JSON or missing required structure | res.status(400).json(err) |
| 401 | Unauthorized | Missing or invalid auth credentials | res.status(401).json(err) |
| 403 | Forbidden | Authenticated but lacks permission | res.status(403).json(err) |
| 404 | Not Found | Resource ID does not exist | res.status(404).json(err) |
| 409 | Conflict | Duplicate unique field (e.g. email) | res.status(409).json(err) |
| 415 | Unsupported Media Type | Content-Type is not application/json | res.status(415).json(err) |
| 422 | Unprocessable Entity | Valid JSON but fails business validation | res.status(422).json(zodErrors) |
| 429 | Too Many Requests | Rate limit exceeded | res.status(429).json(err) |
| 500 | Internal Server Error | Unexpected server exception | res.status(500).json(err) |
The distinction between 400 and 422 is frequently debated. The pragmatic rule: return 400 when the request body is not parseable JSON or the body is completely absent, and return 422 when the JSON parses correctly but the content fails validation rules — wrong type, out-of-range value, missing required field. This maps cleanly to the two failure modes of Zod: the body parser raises a SyntaxError (→ 400) and Zod raises a ZodError (→ 422).
Frequently asked questions
How do I set up Express.js to accept and return JSON?
To set up Express.js to accept and return JSON, call app.use(express.json()) before your route handlers. This single line registers the built-in body-parser middleware that reads the raw HTTP request body, checks that the Content-Type header is application/json, and parses the JSON text into a JavaScript object available as req.body. Without this middleware, req.body is undefined on every POST, PUT, and PATCH request, even when the client sends valid JSON. To return JSON from a route, use res.json(object) instead of res.send(). The res.json() method calls JSON.stringify() on the value, sets the Content-Type response header to application/json; charset=utf-8 automatically, and sends the body. The default body size limit is 100 KB — raise it with express.json({ limit: '10mb' }) if you expect large payloads. Always place app.use(express.json()) before any route that reads req.body, because Express processes middleware in the order it is registered. For how to call this API from a browser, see fetch JSON in JavaScript.
What does express.json() do and when do I need it?
express.json() is a built-in Express middleware that parses incoming request bodies with a Content-Type of application/json and populates req.body with the parsed JavaScript object. It is included with Express 4.16+ — no separate installation needed. You need it any time a route handler reads req.body from a POST, PUT, or PATCH request. Without it, req.body is undefined even when the client sends a perfectly valid JSON body. Internally, express.json() streams the request body into a buffer, enforces the configured size limit (default 100 KB), verifies the Content-Type header, and calls JSON.parse() on the raw text. If JSON.parse() throws — because the client sent malformed JSON — Express automatically returns a 400 Bad Request with a SyntaxError message before your route handler runs. Configure it with options: express.json({ limit: '10mb' }) increases the body size limit, express.json({ strict: false }) allows parsing primitive values like strings and numbers, and express.json({ type: 'application/merge-patch+json' }) restricts parsing to a custom content type.
How do I handle JSON validation errors in Express.js?
Handle JSON validation errors in Express.js with a schema validation library like Zod in a middleware function, then pass errors to a centralized error-handling middleware. The pattern has 3 steps. First, define a Zod schema for the expected request body shape. Second, in your route handler or a dedicated validation middleware, call schema.safeParse(req.body) — safeParse never throws; it returns a result object with a success boolean. If success is false, call res.status(422).json(errorEnvelope) or pass a 422 error to next(err). Third, register a 4-parameter error middleware after all routes to format and send the error response. Return HTTP 422 Unprocessable Entity for validation errors (the request was well-formed JSON but failed business validation) and HTTP 400 Bad Request for completely malformed input. Zod's error.flatten() method converts the ZodError into a flat object with fieldErrors and formErrors arrays that map directly to a machine-readable JSON error response. See validate JSON Schema in JavaScript for schema validation concepts.
How do I structure JSON error responses in Express.js?
Structure JSON error responses in Express.js by defining a consistent envelope and a centralized 4-parameter error middleware. A good error envelope follows RFC 9457 (Problem Details for HTTP APIs): { "type": "...", "title": "Validation Error", "status": 422, "detail": "...", "errors": { "email": ["Invalid email"] } }. The 4 standard RFC 9457 fields are type, title, status, and detail; errors is your extension for field-level validation messages. Register the error middleware with exactly 4 parameters after all route definitions: app.use((err, req, res, next) => { ... }). Express identifies error middleware by arity — if you accidentally write 3 parameters, it becomes regular middleware and errors are silently ignored. In the error handler, set res.status(err.status || 500) and call res.json() with the RFC 9457 envelope. Strip stack traces in production — send the raw error object only in development environments (process.env.NODE_ENV === 'development'). Use a consistent success envelope too: { "data": ..., "meta": { "total": 100 } } for lists, and { "data": { ... } } for single resources. See REST API JSON response format for the full convention.
What HTTP status codes should a JSON REST API use?
A JSON REST API should use HTTP status codes consistently aligned with their RFC meanings. For success: 200 OK for GET and successful PUT/PATCH that return the updated resource; 201 Created for successful POST that creates a new resource (include a Location header pointing to the new resource URL); 204 No Content for DELETE that returns no body. For client errors: 400 Bad Request for syntactically invalid input or missing required fields; 401 Unauthorized when authentication credentials are absent or invalid; 403 Forbidden when the authenticated user lacks permission; 404 Not Found when the resource does not exist; 409 Conflict when a unique constraint is violated (e.g. duplicate email); 415 Unsupported Media Type whenContent-Type is not application/json; 422 Unprocessable Entity when the JSON is well-formed but fails business validation. For server errors: 500 Internal Server Error for unexpected failures; 503 Service Unavailable when downstream dependencies are unavailable. The distinction between 400 and 422 is important: 400 means the request structure is invalid (unparseable JSON); 422 means the structure is valid but the content fails validation rules (wrong type, out-of-range value, invalid email format as detected by Zod).
How do I enable CORS for a JSON Express.js API?
Enable CORS in an Express.js JSON API using the cors npm package, which handles all preflight and response header logic. Install it with npm install cors. For a public API, app.use(cors()) allows all origins. For a private API, pass an options object: cors({ origin: ['https://app.example.com'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization'] }). The allowedHeaders option is critical for JSON APIs: if you omit Content-Type from allowedHeaders, the browser's preflight OPTIONS request fails and all POST/PATCH requests from the browser are blocked with a CORS error — even if your server is running correctly. CORS preflight happens before the actual request: the browser sends an HTTP OPTIONS request with Access-Control-Request-Headers: Content-Type, and the server must respond with Access-Control-Allow-Headers: Content-Type or the browser blocks the follow-up request. Register cors() before your routes and before express.json() to ensure OPTIONS requests are handled before the body parser runs. Set maxAge: 86400 to cache the preflight for 24 hours (86400 seconds) and eliminate the extra round-trip on every JSON POST. In production, never use cors() without an origin allowlist unless the API is intentionally public.
Ready to build your Express.js JSON API?
Use Jsonic's JSON formatter to validate and prettify request and response payloads during development. You can also use the JSON validator to catch syntax errors before sending them to your API.
Open JSON Formatter