Express.js JSON API: express.json(), res.json(), CORS & TypeScript
Last updated:
Express.js handles JSON APIs with express.json() middleware — built-in since Express 4.16.0, replacing body-parser — add it once globally and req.body becomes a parsed JavaScript object on every POST/PUT request. Set limit in express.json({ limit: '1mb' }) to reject oversized payloads; the default is 100kb. Use res.json(data) instead of res.send() — it sets Content-Type: application/json and calls JSON.stringify() automatically. This guide covers express.json() setup, CRUD route patterns, centralized error handling middleware, CORS configuration, input validation with Zod, and TypeScript type-safe request/response patterns. For JSON API design principles and JSON security best practices, see the linked guides.
Setting Up express.json() Middleware
express.json() is a built-in Express middleware function (since v4.16.0) that parses incoming request bodies with a Content-Type: application/json header. Call app.use(express.json()) once before any route definitions and req.body is available as a plain JavaScript object on every matching request. If the JSON is malformed, Express automatically returns a 400 status with a SyntaxError message before your route handler runs.
import express from 'express'
const app = express()
// ── Global JSON middleware ─────────────────────────────────────────
// Must be registered BEFORE route handlers
app.use(express.json())
// Default limit: 100kb — raise for APIs that accept large payloads
// app.use(express.json({ limit: '1mb' }))
// ── strict: false allows top-level arrays and primitives ──────────
// By default strict: true — only objects and arrays are accepted
app.use(express.json({ strict: false }))
// ── Route-level middleware — override limit for one endpoint ──────
const bulkParser = express.json({ limit: '10mb' })
app.post('/import', bulkParser, (req, res) => {
// req.body available as parsed JS object
res.json({ imported: req.body.length })
})
// ── Verify JSON body was parsed ───────────────────────────────────
app.post('/echo', (req, res) => {
if (!req.is('application/json')) {
return res.status(415).json({ error: 'Content-Type must be application/json' })
}
res.json({ received: req.body })
})
// ── Handling the SyntaxError from malformed JSON ─────────────────
// express.json() calls next(err) with a SyntaxError on bad JSON
// Catch it in your error-handling middleware:
app.use((err, req, res, next) => {
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({ error: 'Invalid JSON', detail: err.message })
}
next(err)
})
app.listen(3000)The most common mistake: registering routes before app.use(express.json()), leaving req.body as undefined. Order matters — middleware runs in the order it is registered. For Express Router instances, apply express.json() on the app before mounting the router, or call router.use(express.json()) at the top of the router file. The strict option (default true) rejects top-level primitives and arrays — set it to false if your API accepts bare JSON arrays or strings as request bodies.
CRUD Routes: GET, POST, PUT, DELETE with res.json()
res.json(data) is the correct method for all JSON API responses — it sets Content-Type: application/json; charset=utf-8, calls JSON.stringify() with the app's json replacer and json spaces settings, and ends the response. res.status(code).json(data) chains a status code. Use Express Router to group related routes by resource, keeping each router file focused on one domain object.
import express, { Request, Response } from 'express'
const router = express.Router()
// ── GET /users — list all ─────────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const users = await db.user.findMany()
res.json(users) // 200 OK, Content-Type: application/json
})
// ── GET /users/:id — get one ──────────────────────────────────────
router.get('/:id', async (req: Request, res: Response, next) => {
try {
const user = await db.user.findUnique({ where: { id: Number(req.params.id) } })
if (!user) {
return res.status(404).json({ error: 'User not found' })
}
res.json(user)
} catch (err) {
next(err) // forward to centralized error handler
}
})
// ── POST /users — create ──────────────────────────────────────────
router.post('/', async (req: Request, res: Response, next) => {
try {
const user = await db.user.create({ data: req.body })
res.status(201).json(user) // 201 Created
} catch (err) {
next(err)
}
})
// ── PUT /users/:id — full replace ─────────────────────────────────
router.put('/:id', async (req: Request, res: Response, next) => {
try {
const user = await db.user.update({
where: { id: Number(req.params.id) },
data: req.body,
})
res.json(user) // 200 OK
} catch (err) {
next(err)
}
})
// ── PATCH /users/:id — partial update ────────────────────────────
router.patch('/:id', async (req: Request, res: Response, next) => {
try {
const user = await db.user.update({
where: { id: Number(req.params.id) },
data: req.body,
})
res.json(user)
} catch (err) {
next(err)
}
})
// ── DELETE /users/:id — delete ────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response, next) => {
try {
await db.user.delete({ where: { id: Number(req.params.id) } })
res.status(204).send() // 204 No Content — no body
} catch (err) {
next(err)
}
})
export default router
// ── Mount router in app.ts ─────────────────────────────────────────
// app.use('/users', usersRouter)
Return 204 No Content (no body) for successful DELETE responses — clients expect an empty body for 204 and some HTTP clients throw on a 204 response that includes a body. Use res.status(201).json(created) for POST responses so clients know the resource was created and receive the server-assigned ID. Always call next(err) in catch blocks rather than writing inline error responses — this routes errors to the centralized handler and keeps route code clean. See JSON API design for REST status code conventions.
Request Validation with Zod and express-validator
Never trust req.body without validation — it can contain unexpected types, extra fields, or missing required properties. Zod is the preferred validation library for TypeScript Express projects: schemas double as TypeScript types via z.infer, and safeParse() returns a discriminated union with full type inference. JSON schema validation covers the broader validation ecosystem.
import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'
// ── Define schema — doubles as TypeScript type ────────────────────
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
})
// Derive the TypeScript type — no duplicate type definition needed
type CreateUser = z.infer<typeof createUserSchema>
// ── Validation middleware factory ─────────────────────────────────
function validate<T>(schema: z.ZodSchema<T>) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body)
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
issues: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
})
}
// Replace req.body with the validated, typed data
req.body = result.data
next()
}
}
// ── Use in routes ─────────────────────────────────────────────────
router.post('/', validate(createUserSchema), async (req: Request, res: Response) => {
const user: CreateUser = req.body // fully typed, validated
const created = await db.user.create({ data: user })
res.status(201).json(created)
})
// ── Validate URL params and query strings ─────────────────────────
const paramsSchema = z.object({ id: z.coerce.number().int().positive() })
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
})
router.get('/', (req, res, next) => {
const q = querySchema.safeParse(req.query)
if (!q.success) return res.status(400).json({ error: 'Invalid query', issues: q.error.issues })
const { page, limit } = q.data
// page and limit are typed as number, not string
res.json({ page, limit })
})
// ── express-validator alternative ─────────────────────────────────
import { body, validationResult } from 'express-validator'
const validateCreateUser = [
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('email').isEmail().normalizeEmail(),
body('age').optional().isInt({ min: 0, max: 150 }),
(req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req)
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() })
}
next()
},
]
router.post('/legacy', validateCreateUser, async (req, res) => {
res.status(201).json(await db.user.create({ data: req.body }))
})Use z.coerce.number() for URL params and query strings — they arrive as strings and coercion converts them to numbers automatically. Without coercion, z.number() rejects string inputs and you would need manual casting. Strip unknown fields from validated data with schema.strip() (the Zod default) — this prevents mass-assignment vulnerabilities where a client sends extra fields like {"{ isAdmin: true }"} that your database model would silently accept.
Centralized Error Handling Middleware
Express identifies error-handling middleware by its 4-parameter signature: (err, req, res, next). Register it after all routes and other middleware with app.use(). Route handlers pass errors to it by calling next(err). This single handler receives all errors from all routes, keeping error-response formatting consistent across the entire API.
import { Request, Response, NextFunction } from 'express'
// ── Custom error class with HTTP status ───────────────────────────
class AppError extends Error {
constructor(
public message: string,
public status: number = 500,
public code?: string,
) {
super(message)
this.name = 'AppError'
}
}
// Convenience factories
const notFound = (msg = 'Not found') => new AppError(msg, 404, 'NOT_FOUND')
const badRequest = (msg = 'Bad request') => new AppError(msg, 400, 'BAD_REQUEST')
const forbidden = (msg = 'Forbidden') => new AppError(msg, 403, 'FORBIDDEN')
// ── Centralized error handler — register LAST ─────────────────────
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
// 1. Log the error (use a logger like pino or winston in production)
console.error({
message: err.message,
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined,
path: req.path,
method: req.method,
})
// 2. Handle known error types
if (err instanceof AppError) {
return res.status(err.status).json({
error: err.message,
code: err.code,
})
}
// 3. Handle express.json() SyntaxError (malformed JSON body)
if (err instanceof SyntaxError && 'body' in err) {
return res.status(400).json({ error: 'Invalid JSON in request body' })
}
// 4. Handle Prisma / database errors
if (err.constructor.name === 'PrismaClientKnownRequestError') {
if ((err as any).code === 'P2025') {
return res.status(404).json({ error: 'Record not found' })
}
if ((err as any).code === 'P2002') {
return res.status(409).json({ error: 'Duplicate value for unique field' })
}
}
// 5. Default: 500 Internal Server Error — hide details in production
res.status(500).json({
error: 'Internal server error',
detail: process.env.NODE_ENV === 'development' ? err.message : undefined,
})
})
// ── 404 catch-all — register after all routes, before error handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found', path: req.path })
})
// ── Async wrapper for Express 4 ───────────────────────────────────
// Express 4 does not forward thrown errors from async handlers
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next)
}
}
// Usage:
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.user.findUniqueOrThrow({ where: { id: Number(req.params.id) } })
res.json(user)
// findUniqueOrThrow throws — caught by asyncHandler → forwarded to error middleware
}))In Express 5 (currently in beta), async route handlers forward thrown errors to the error middleware automatically — the asyncHandler wrapper is no longer needed. Until then, use the wrapper or a library like express-async-errors which monkey-patches Express to add the same behavior. Never send two responses from one handler (double res.json()) — always return after sending a response to prevent "Cannot set headers after they are sent" errors.
CORS Configuration for JSON APIs
Cross-Origin Resource Sharing (CORS) controls which domains browsers allow to make requests to your API. The cors npm package handles OPTIONS preflight requests and sets the required headers automatically. In development, cors() with no options allows all origins; production must restrict origins to prevent unauthorized cross-origin access. See the JSON security guide for broader API security considerations.
import cors from 'cors'
// ── Development: allow all origins ────────────────────────────────
app.use(cors())
// ── Production: restrict to specific origins ──────────────────────
app.use(cors({
origin: 'https://app.example.com',
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true, // allow cookies and Authorization headers
maxAge: 86400, // preflight cache: 24 hours (seconds)
}))
// ── Multiple allowed origins ──────────────────────────────────────
const allowedOrigins = [
'https://app.example.com',
'https://admin.example.com',
]
app.use(cors({
origin: (origin, callback) => {
// Allow requests with no origin (curl, Postman, server-to-server)
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error('CORS: origin not allowed'))
}
},
credentials: true,
}))
// ── Per-route CORS — different rules for public vs. authenticated ──
const publicCors = cors({ origin: '*' })
const restrictedCors = cors({ origin: 'https://app.example.com', credentials: true })
app.get('/public/data', publicCors, handler) // any origin
app.post('/user/data', restrictedCors, handler) // restricted + credentials
// ── Manual CORS headers (no cors package) ─────────────────────────
app.use((req, res, next) => {
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com')
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization')
if (req.method === 'OPTIONS') {
return res.sendStatus(204)
}
next()
})Never set Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true — browsers reject this combination and the preflight will fail. When using cookies or the Authorization header, you must specify an exact origin string. The maxAge option controls how long browsers cache the preflight response — set it high (86400 = 24 hours) in production to reduce OPTIONS request overhead. The cors package handles the OPTIONS preflight automatically; without it, you must respond to OPTIONS requests manually before calling next().
Rate Limiting and Security Headers with helmet
helmet sets security-related HTTP response headers in a single middleware call, protecting against common web vulnerabilities. express-rate-limit adds request rate limiting per IP address, preventing abuse and denial-of-service attacks. Both are essential for any production Express JSON API. See the JSON rate limiting guide for advanced strategies.
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
// ── helmet: set security headers ──────────────────────────────────
// Apply early — before routes
app.use(helmet())
// Sets: Content-Security-Policy, X-Frame-Options, X-Content-Type-Options,
// Strict-Transport-Security (HSTS), Referrer-Policy, and more
// ── Basic rate limiter: 100 requests per 15 minutes per IP ────────
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // requests per window per IP
standardHeaders: true, // return RateLimit-* headers (RFC 6585)
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
handler: (req, res) => {
res.status(429).json({ error: 'Rate limit exceeded', retryAfter: res.getHeader('Retry-After') })
},
})
app.use('/api/', limiter)
// ── Stricter limit for auth endpoints ─────────────────────────────
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10, // only 10 login attempts per 15 min
skipSuccessfulRequests: true, // don't count successful logins
message: { error: 'Too many login attempts' },
})
app.post('/auth/login', authLimiter, loginHandler)
// ── Keyed by user ID (after authentication) ───────────────────────
const userLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 60,
keyGenerator: (req) => req.user?.id?.toString() ?? req.ip ?? 'unknown',
})
// ── helmet: customize for JSON-only APIs ─────────────────────────
app.use(helmet({
contentSecurityPolicy: false, // disable CSP for pure JSON APIs (no HTML)
crossOriginEmbedderPolicy: false,
}))
// ── Additional security middleware ────────────────────────────────
// Remove X-Powered-By header (Express sets it by default)
app.disable('x-powered-by') // or use helmet() which does this too
// Set Retry-After header on rate limit responses
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
handler: (req, res, next, options) => {
res
.status(options.statusCode)
.set('Retry-After', String(Math.ceil(options.windowMs / 1000)))
.json(options.message)
},
})Place app.use(helmet()) as the first middleware before any route definitions or other middleware — security headers must be set on every response, including error responses. Use skipSuccessfulRequests: true on auth limiters to avoid penalizing users who log in successfully. For distributed deployments behind a load balancer or reverse proxy, set app.set('trust proxy', 1) so express-rate-limit reads the real client IP from the X-Forwarded-For header instead of the proxy IP — without this, all requests appear to come from the same IP and rate limiting does not work.
TypeScript Setup: Typed Request and Response Objects
TypeScript + Express requires @types/express for type definitions. The Request generic accepts four type parameters: Request<Params, ResBody, ReqBody, Query>. Typing request bodies eliminates runtime surprises and enables IDE autocompletion on req.body properties. Combined with Zod, you get compile-time and runtime type safety from a single schema definition.
// npm install --save-dev @types/express @types/node
// npm install express
import express, {
Request,
Response,
NextFunction,
RequestHandler,
Router,
} from 'express'
import { z } from 'zod'
// ── Type the request body ─────────────────────────────────────────
interface CreateUserBody {
name: string
email: string
role?: 'admin' | 'editor' | 'viewer'
}
// Request<Params, ResBody, ReqBody, Query>
app.post(
'/users',
async (req: Request<{}, {}, CreateUserBody>, res: Response) => {
const { name, email, role = 'viewer' } = req.body // fully typed
const user = await db.user.create({ data: { name, email, role } })
res.status(201).json(user)
}
)
// ── Type URL params ───────────────────────────────────────────────
interface UserParams {
id: string // URL params are always strings
}
app.get(
'/users/:id',
async (req: Request<UserParams>, res: Response) => {
const id = Number(req.params.id) // coerce to number
const user = await db.user.findUnique({ where: { id } })
if (!user) return res.status(404).json({ error: 'Not found' })
res.json(user)
}
)
// ── Type query parameters ─────────────────────────────────────────
interface UserQuery {
page?: string
limit?: string
role?: string
}
app.get(
'/users',
async (req: Request<{}, {}, {}, UserQuery>, res: Response) => {
const page = Number(req.query.page ?? 1)
const limit = Number(req.query.limit ?? 20)
const users = await db.user.findMany({ skip: (page - 1) * limit, take: limit })
res.json({ data: users, page, limit })
}
)
// ── Standalone typed handler using RequestHandler ─────────────────
const getUser: RequestHandler<UserParams> = async (req, res, next) => {
try {
const user = await db.user.findUnique({ where: { id: Number(req.params.id) } })
if (!user) return res.status(404).json({ error: 'Not found' })
res.json(user)
} catch (err) {
next(err)
}
}
app.get('/users/:id', getUser)
// ── Extend Request with custom properties (e.g., auth user) ──────
declare global {
namespace Express {
interface Request {
user?: { id: number; role: string }
}
}
}
// Now req.user is typed throughout the app
const authMiddleware: RequestHandler = (req, res, next) => {
req.user = verifyToken(req.headers.authorization)
next()
}
// ── Zod schema → TypeScript type → typed req.body ─────────────────
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
type CreateUser = z.infer<typeof schema> // { name: string; email: string }
const createUser: RequestHandler<{}, {}, CreateUser> = async (req, res) => {
const result = schema.safeParse(req.body)
if (!result.success) return res.status(400).json({ errors: result.error.issues })
const user = await db.user.create({ data: result.data })
res.status(201).json(user)
}Use declare global { namespace Express { interface Request { user?: User } } } in a types/express.d.ts file to add custom properties to the Request interface globally — this avoids casting (req as any).user throughout the codebase. TypeScript does not enforce that req.body matches your declared type at runtime — always combine TypeScript types with a runtime validator like Zod for actual safety. The RequestHandler utility type is cleaner than inline function signatures when defining handlers as standalone constants.
Key Terms
- middleware
- A function in Express with the signature
(req, res, next)that sits in the request-response pipeline. Middleware can read and modifyreqandres, end the response by callingres.json()or similar, or pass control to the next middleware by callingnext(). Registered withapp.use()(global) or on specific routes (route-level). Order of registration determines execution order — middleware registered before routes runs on every request; route-level middleware runs only on matching requests. Error-handling middleware has a 4-parameter signature:(err, req, res, next). - express.json()
- A built-in Express middleware function (since v4.16.0) that parses request bodies with
Content-Type: application/json. It reads the request stream, callsJSON.parse()on the body, and attaches the result toreq.body. Accepts an options object:limit(max body size, default'100kb'),strict(only accept objects/arrays, defaulttrue), andreviver(a JSON.parse reviver function). Returns a 400 response on malformed JSON before route handlers run. Internally uses thebody-parserpackage — Express bundled it in v4.16.0 to remove it as a required separate dependency. - res.json()
- An Express response method that sends a JSON response. It sets the
Content-Typeheader toapplication/json, callsJSON.stringify()on the argument using the app-leveljson replacerandjson spacessettings, and ends the response. Chain withres.status(code)to set an HTTP status code:res.status(201).json(data). Handlesnull,undefined, booleans, numbers, arrays, and objects correctly. Prefer it overres.send()for all JSON API endpoints —res.send()infers the content type from the value and can produce unexpected results for objects. - CORS
- Cross-Origin Resource Sharing — a browser security mechanism that controls which origins (domain + port + protocol combinations) are permitted to make HTTP requests to a server from JavaScript. The browser sends an
Originheader on cross-origin requests; the server responds withAccess-Control-Allow-Originindicating which origins are allowed. For requests with custom headers or non-simple methods, the browser sends a preflight OPTIONS request first. Thecorsnpm package handles all CORS header logic and OPTIONS preflight responses in Express. Without correct CORS headers, browsers block the response from JavaScript — the request reaches the server, but the client-side code cannot read the response. - error-handling middleware
- A special Express middleware function with exactly 4 parameters:
(err, req, res, next). Express identifies it as an error handler by the presence of the 4th parameter — omitting it makes Express treat it as regular middleware and skip it for errors. Register error-handling middleware after all routes and otherapp.use()calls. Route handlers pass errors to it by callingnext(err)with a non-falsy argument. A single centralized error handler ensures consistent JSON error responses across all routes and prevents error-handling logic from being duplicated in every route. - helmet
- An Express middleware package that sets security-related HTTP response headers. A single
app.use(helmet())call enables 11 security headers includingContent-Security-Policy,X-Frame-Options: DENY,X-Content-Type-Options: nosniff,Strict-Transport-Security(HSTS), andReferrer-Policy. Each header mitigates a specific class of attack — HSTS forces HTTPS,X-Content-Type-Optionsprevents MIME-sniffing attacks,X-Frame-Optionsprevents clickjacking. Individual protections can be enabled or disabled via the options object. Helmet does not replace authentication, authorization, or input validation — it is a complementary security layer for HTTP-level protections.
FAQ
How do I parse JSON request bodies in Express?
Add app.use(express.json()) before your route handlers. This middleware is built into Express since v4.16.0 — no additional package needed. After adding it, req.body is a parsed JavaScript object for any POST, PUT, or PATCH request with Content-Type: application/json. If req.body is undefined, the two most common causes are: (1) app.use(express.json()) is registered after the route, or (2) the client is not sending the Content-Type: application/json header. Set a body size limit with express.json(({ limit: '1mb' }) — the default is 100kb. Malformed JSON triggers an automatic 400 response before your route handler runs.
What is the difference between res.json() and res.send() in Express?
res.json(data) always sets Content-Type: application/json, calls JSON.stringify(), and sends the response. res.send(data) infers Content-Type from the data type — a string gets text/html, a Buffer gets application/octet-stream, and an object gets application/json. For JSON APIs, always use res.json() — it is explicit and consistent. res.json() also respects app-level json replacer and json spaces settings configured with app.set('json spaces', 2), while res.send() does not. res.json(null) correctly sends the string "null" with the JSON content type; res.send(null) sends an empty body.
How do I handle errors in an Express JSON API?
Define a centralized error-handling middleware with exactly 4 parameters — (err, req, res, next) — and register it after all routes with app.use(). Pass errors to it from route handlers with next(err). Inside the handler, read err.status or err.statusCode for the HTTP status (default 500), and respond with res.status(err.status || 500).json(({ error: err.message }). For async route handlers in Express 4, wrap them in a try/catch and call next(err) in the catch block, or use an asyncHandler wrapper. Express 5 (beta) forwards async errors automatically without wrapping.
How do I enable CORS in Express?
Install the cors package (npm install cors) and add app.use(cors()) to allow all origins — this is fine for development. In production, restrict to specific origins: app.use(cors(({ origin: 'https://example.com', credentials: true })). For multiple origins, pass an array or a callback function that validates the origin argument. Never combine origin: '*' with credentials: true — browsers block this combination. The cors package handles OPTIONS preflight requests automatically. For per-route CORS policies, pass cors(options) directly to individual route handlers instead of using it globally.
How do I validate JSON input in Express?
Use Zod for TypeScript projects: define a schema with z.object(({...}), call schema.safeParse(req.body), and return a 400 response if result.success is false. Extract the validated data from result.data — it is fully typed via z.infer<typeof schema>. For URL params and query strings, use z.coerce.number() to convert strings to numbers automatically. Never trust req.body without validation — it can contain extra fields that cause mass-assignment vulnerabilities. See JSON schema validation for broader validation patterns.
How do I set a body size limit in Express?
Pass the limit option to express.json(): app.use(express.json(({ limit: '1mb' })). The value accepts a number (bytes) or a string with a unit: '100kb', '1mb', '10mb'. The default is '100kb'. When a request body exceeds the limit, Express returns 413 Payload Too Large automatically before your route handler runs. To apply a higher limit to only one route, pass a separate express.json(({ limit: '10mb' }) instance directly to that route handler instead of raising the global limit. For file uploads, use multer instead — express.json() only handles application/json bodies.
How do I use Express with TypeScript?
Install type definitions: npm install --save-dev @types/express @types/node. Import types: import express, { Request, Response, NextFunction } from 'express'. Type route handlers with the Request generic: Request<Params, ResBody, ReqBody, Query> — e.g., Request<{ id: string }, {}, CreateUserBody> types the URL params and request body. Use z.infer<typeof schema> with Zod to derive TypeScript types from your validation schemas without writing types twice. Extend the Request interface globally via declare global { namespace Express { interface Request { user?: User } } } in a types/express.d.ts file to add custom properties like the authenticated user.
How do I return a 404 JSON error in Express?
For resources not found within a route handler, call res.status(404).json(({ error: 'User not found' }) directly. For unmatched routes (path not defined in your API), add a catch-all middleware after all routes: app.use((req, res) => { res.status(404).json({ error: 'Not found', path: req.path }) }). A reusable pattern: create a NotFoundError extends Error class with status = 404, throw it in route handlers, and let the centralized error middleware format the 404 JSON response consistently. Always return JSON (not HTML) from JSON API 404 responses — Express's default 404 is an HTML page that breaks clients expecting JSON.
Further reading and primary sources
- Express.js: express.json() docs — Official reference for express.json() options including limit, strict, and reviver
- Express.js: Error Handling Guide — Official guide to writing error-handling middleware and using next(err)
- cors npm package — CORS middleware for Express with all configuration options and examples
- Zod Documentation — TypeScript-first schema validation with static type inference — primary reference
- helmet.js — Security headers middleware for Express — list of all headers set and configuration options