JSON in Express.js: express.json() Middleware, res.json(), req.body & Error Handling

Last updated:

Express.js handles JSON request bodies and responses through two complementary APIs: express.json() middleware parses incoming Content-Type: application/json bodies into req.body as a JavaScript object, and res.json(data) serializes any value to JSON, sets the correct Content-Type header, and sends the response. Both have been built into Express since version 4.16.0 — no external body-parser package is required. Register app.use(express.json()) before your route handlers; without it, req.body is undefined regardless of the request's Content-Type. express.json({'{ limit: \'10mb\' }'}) overrides the 100kb default body size limit. res.status(422).json({'{ errors: result.error.issues }'}) chains a status code with a JSON response in a single call — status() must precede json(). This guide covers express.json() configuration, reading req.body, res.json() and res.status().json(), JSON error handling middleware, body size limits, and validating request JSON with Zod.

Registering express.json() Middleware

express.json() is a built-in middleware function in Express 4.16.0+ that parses incoming request bodies with a Content-Type: application/json header and populates req.body with the parsed JavaScript object. Call app.use(express.json()) at the top of your application, before any route definitions that read req.body. Middleware runs in registration order — a route defined before express.json() will not see a populated body.

import express from 'express'

const app = express()

// ── Register BEFORE route handlers ────────────────────────────
// express.json() parses Content-Type: application/json bodies
// into req.body as a JavaScript object.
app.use(express.json())

// ── Also register urlencoded for HTML form bodies ──────────────
// (not JSON, but common companion middleware)
app.use(express.urlencoded({ extended: true }))

// ── Routes now have access to req.body ────────────────────────
app.post('/users', (req, res) => {
  console.log(req.body) // { name: 'Alice', email: 'alice@example.com' }
  res.status(201).json({ id: 1, ...req.body })
})

app.listen(3000)

// ── Route-level middleware (applies to one route only) ────────
// Useful when only specific routes accept JSON bodies
app.post(
  '/upload',
  express.json({ limit: '5mb' }), // route-specific limit
  (req, res) => {
    res.json({ received: true })
  }
)

// ── WRONG: registering after routes — req.body will be undefined ──
// app.post('/broken', handler)   // ← req.body === undefined here
// app.use(express.json())        // ← too late, body already skipped

// ── Pre-4.16.0 pattern (no longer needed) ─────────────────────
// import bodyParser from 'body-parser'
// app.use(bodyParser.json())  // equivalent to express.json()
// bodyParser is still maintained but is redundant since Express 4.16.0

Express 4.16.0 ships express.json() as a thin wrapper around the body-parser package — the implementation is identical. If you see older Express codebases importing body-parser separately, that package is still maintained but no longer necessary. For new projects, use express.json() directly. Route-level middleware registration is useful when only certain endpoints accept JSON bodies, keeping the middleware surface minimal and explicit.

Reading JSON Request Bodies with req.body

After express.json() runs, req.body contains the parsed JavaScript object from the request body. If the body is a JSON array, req.body is an array. If the body is a primitive (true, 42, "hello"), req.body is that primitive value. If the request has no body or a non-JSON Content-Type, req.body is undefined. Always validate req.body before use — its shape is entirely controlled by the client.

import express, { Request, Response } from 'express'

const app = express()
app.use(express.json())

// ── Reading a JSON object body ─────────────────────────────────
app.post('/users', (req: Request, res: Response) => {
  // req.body is the parsed JSON object
  // { name: 'Alice', email: 'alice@example.com', age: 30 }
  const { name, email, age } = req.body

  // Always check for required fields before using them
  if (!name || !email) {
    return res.status(400).json({ message: 'name and email are required' })
  }

  res.status(201).json({ id: 42, name, email, age })
})

// ── Reading a JSON array body ──────────────────────────────────
app.post('/bulk-create', (req: Request, res: Response) => {
  // Body: [{ name: 'Alice' }, { name: 'Bob' }]
  if (!Array.isArray(req.body)) {
    return res.status(400).json({ message: 'Expected a JSON array' })
  }
  const items = req.body
  res.json({ created: items.length, ids: items.map((_, i) => i + 1) })
})

// ── When req.body is undefined — common mistakes ───────────────
app.post('/debug', (req: Request, res: Response) => {
  // Possible reasons req.body is undefined:
  // 1. express.json() not registered at all
  // 2. express.json() registered AFTER this route
  // 3. Client sent request without Content-Type: application/json
  // 4. Client sent an empty body

  if (req.body === undefined) {
    return res.status(400).json({
      message: 'No JSON body received',
      tip: 'Ensure Content-Type: application/json header is set',
    })
  }

  res.json({ received: req.body })
})

// ── TypeScript: typed request body ────────────────────────────
interface CreateUserBody {
  name: string
  email: string
  age?: number
}

app.post('/typed-users', (req: Request<{}, {}, CreateUserBody>, res: Response) => {
  // req.body is typed as CreateUserBody
  // Note: TypeScript types are compile-time only — validate at runtime too!
  const { name, email } = req.body
  res.status(201).json({ id: 1, name, email })
})

TypeScript's generic Request<Params, ResBody, ReqBody> type allows typing req.body at compile time, but this is purely a compile-time annotation — no runtime validation occurs. The client can send any shape and TypeScript will not catch it at runtime. Always pair TypeScript request body types with runtime validation using Zod, Joi, or express-validator. The safest pattern is to type req.body as unknown and narrow it through a validated Zod schema before using it.

Sending JSON Responses with res.json() and res.status()

res.json(data) is the primary method for sending JSON responses in Express. It calls JSON.stringify(data), sets the Content-Type: application/json; charset=utf-8 header, and sends the response with status 200 by default. Chain res.status(code) before res.json() to set a non-200 status — the two calls return the same response object and can be chained fluently.

import express, { Request, Response } from 'express'

const app = express()
app.use(express.json())

// ── 200 OK with JSON body ──────────────────────────────────────
app.get('/users/:id', (req: Request, res: Response) => {
  const user = { id: req.params.id, name: 'Alice', email: 'alice@example.com' }
  res.json(user)
  // Sets: Content-Type: application/json; charset=utf-8
  // Body: {"id":"1","name":"Alice","email":"alice@example.com"}
})

// ── 201 Created ────────────────────────────────────────────────
app.post('/users', (req: Request, res: Response) => {
  const newUser = { id: 42, ...req.body }
  res.status(201).json(newUser)
  // status() MUST come before json()
})

// ── 400 Bad Request ────────────────────────────────────────────
app.post('/validate', (req: Request, res: Response) => {
  if (!req.body.email) {
    return res.status(400).json({
      error:   'validation_error',
      message: 'email is required',
    })
  }
  res.json({ valid: true })
})

// ── 404 Not Found ──────────────────────────────────────────────
app.get('/items/:id', async (req: Request, res: Response) => {
  const item = await db.findById(req.params.id)
  if (!item) {
    return res.status(404).json({
      error:   'not_found',
      message: `Item ${req.params.id} not found`,
    })
  }
  res.json(item)
})

// ── 204 No Content (no body) ───────────────────────────────────
app.delete('/users/:id', async (req: Request, res: Response) => {
  await db.deleteUser(req.params.id)
  res.status(204).send() // 204 must have no body — don't use res.json()
})

// ── JSON arrays ────────────────────────────────────────────────
app.get('/users', async (req: Request, res: Response) => {
  const users = await db.listUsers()
  res.json(users) // sends a JSON array directly
})

// ── res.json() vs res.send() comparison ───────────────────────
// res.json(obj)    → always Content-Type: application/json + JSON.stringify
// res.send(obj)    → infers Content-Type from type (object → JSON, string → HTML)
// res.send(string) → Content-Type: text/html — NOT JSON
// res.json(string) → Content-Type: application/json, body: ""hello""

// ── Chaining pattern ──────────────────────────────────────────
// res.status(422).json({ errors }) is idiomatic Express
// Equivalent to:
// res.status(422)
// res.json({ errors })
// But NOT: res.json({ errors }).status(422) ← wrong order, throws

A common mistake is calling res.json() or res.send() more than once in a single handler — Express will throw a "Cannot set headers after they are sent" error because the HTTP response stream is already closed after the first call. Use early return statements after each response call to prevent execution from continuing to a second call. The pattern return res.status(400).json(...) both sends the response and returns from the handler in one line.

Configuring Body Size Limits and Options

express.json() accepts an options object that controls how request bodies are parsed. The most commonly used option is limit, which sets the maximum request body size. Other options control which Content-Types trigger parsing, the character set, and whether to inflate compressed bodies.

import express from 'express'

const app = express()

// ── Default: 100kb limit ───────────────────────────────────────
app.use(express.json())
// Equivalent to:
app.use(express.json({ limit: '100kb' }))

// ── Raise limit for file-upload endpoints ─────────────────────
// Route-level middleware overrides the global limit for /upload only
app.post(
  '/upload',
  express.json({ limit: '10mb' }),  // 10 megabytes
  (req, res) => {
    res.json({ size: JSON.stringify(req.body).length })
  }
)

// ── All available options ──────────────────────────────────────
app.use(express.json({
  // Maximum request body size. String (with unit suffix) or bytes.
  limit: '100kb',              // default

  // Function that returns true to parse the body.
  // Default: checks Content-Type === 'application/json'
  type: 'application/json',    // or a function: (req) => boolean

  // If true, deflate and gzip compressed bodies are decoded.
  inflate: true,               // default

  // Reviver function passed to JSON.parse (like JSON.parse(str, reviver))
  // Useful for transforming values during parsing
  reviver: (key, value) => {
    // Convert ISO date strings to Date objects during parsing
    if (typeof value === 'string' && /^d{4}-d{2}-d{2}T/.test(value)) {
      return new Date(value)
    }
    return value
  },

  // Character set to use if not specified in Content-Type
  defaultCharset: 'utf-8',     // default
}))

// ── Handling 413 Payload Too Large ────────────────────────────
// When body exceeds limit, Express calls next(err) with:
//   err.type   === 'entity.too.large'
//   err.status === 413

// Catch it in the error handler:
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
  if (err.type === 'entity.too.large') {
    return res.status(413).json({
      error:   'payload_too_large',
      message: 'Request body exceeds the maximum allowed size of 100kb',
    })
  }
  next(err)
})

// ── Custom Content-Type parsing ───────────────────────────────
// Accept application/vnd.api+json (JSON:API content type) as JSON
app.use(express.json({ type: ['application/json', 'application/vnd.api+json'] }))

// ── Global app-level JSON serialization settings ──────────────
// Affects all res.json() calls
app.set('json spaces', 2)         // pretty-print with 2-space indent
app.set('json replacer', (key: string, value: unknown) => {
  // Remove undefined and null values from all JSON responses
  if (value === undefined) return undefined  // omits the key
  return value
})

The reviver option is particularly useful for APIs that exchange ISO 8601 date strings — by converting them to Date objects during parsing, handlers receive proper Date instances in req.body without manual conversion. Be careful with the reviver in multi-tenant or public-facing APIs, as it runs on untrusted input; always validate the resulting object with Zod or another schema validator before using values from req.body.

JSON Error Handling Middleware

Express error-handling middleware takes exactly 4 parameters: (err, req, res, next). Register it after all routes as the last app.use() call. It catches errors from express.json() (parse failures, size limit exceeded) as well as errors forwarded by route handlers via next(err). Without a proper error handler, Express returns HTML error pages with stack traces — unacceptable for a JSON API.

import express, { Request, Response, NextFunction } from 'express'

const app = express()
app.use(express.json())

// ── Routes ────────────────────────────────────────────────────
app.get('/users', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const users = await db.listUsers()
    res.json(users)
  } catch (err) {
    next(err)  // forward to error handler
  }
})

app.post('/users', async (req: Request, res: Response, next: NextFunction) => {
  try {
    const user = await db.createUser(req.body)
    res.status(201).json(user)
  } catch (err) {
    next(err)
  }
})

// ── 404 handler (no route matched) ────────────────────────────
// Must come AFTER all routes, BEFORE the error handler
app.use((req: Request, res: Response) => {
  res.status(404).json({
    error:   'not_found',
    message: `Route ${req.method} ${req.path} not found`,
  })
})

// ── Error-handling middleware ──────────────────────────────────
// MUST have exactly 4 parameters for Express to treat it as an error handler.
// Register LAST — after all routes and the 404 handler.
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  // ── JSON parse error (malformed request body) ────────────────
  if (err.type === 'entity.parse.failed') {
    return res.status(400).json({
      error:   'invalid_json',
      message: 'Request body contains invalid JSON',
    })
  }

  // ── Body too large ───────────────────────────────────────────
  if (err.type === 'entity.too.large') {
    return res.status(413).json({
      error:   'payload_too_large',
      message: 'Request body exceeds the size limit',
    })
  }

  // ── Custom application errors ────────────────────────────────
  if (err.status && err.status < 500) {
    return res.status(err.status).json({
      error:   err.code ?? 'client_error',
      message: err.message,
    })
  }

  // ── Unexpected server errors ─────────────────────────────────
  // Log full error for debugging, return generic message to client
  console.error('Unhandled error:', err)
  res.status(500).json({
    error:   'internal_server_error',
    message: 'An unexpected error occurred',
    // NEVER expose err.message or err.stack in production
  })
})

// ── Custom error class ─────────────────────────────────────────
class AppError extends Error {
  constructor(
    public status: number,
    public code: string,
    message: string,
  ) {
    super(message)
    this.name = 'AppError'
  }
}

// Throw in route handlers — caught by the error middleware above:
// throw new AppError(404, 'user_not_found', 'User 42 does not exist')

The 4-parameter signature is the only way Express distinguishes error-handling middleware from regular middleware — do not omit the next parameter even if you don't use it, or Express will not recognize the function as an error handler. For async route handlers, wrap them in try/catch and call next(err), or use a utility like express-async-errors that patches Express to automatically forward async rejections to the error handler without try/catch boilerplate.

Validating Request JSON Bodies with Zod

Zod is the recommended validation library for Express JSON APIs — it validates req.body at runtime and infers TypeScript types from schemas, eliminating the need for separate type annotations. The safeParse() method never throws, returning a result object that lets you return a structured 422 error response when validation fails.

import express, { Request, Response, NextFunction } from 'express'
import { z } from 'zod'

const app = express()
app.use(express.json())

// ── Define Zod schemas for request bodies ─────────────────────
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(['user', 'admin']).default('user'),
})

const UpdateUserSchema = CreateUserSchema.partial().refine(
  (data) => Object.keys(data).length > 0,
  { message: 'At least one field must be provided for update' }
)

type CreateUserBody = z.infer<typeof CreateUserSchema>
type UpdateUserBody = z.infer<typeof UpdateUserSchema>

// ── Inline validation ─────────────────────────────────────────
app.post('/users', (req: Request, res: Response) => {
  const result = CreateUserSchema.safeParse(req.body)

  if (!result.success) {
    return res.status(422).json({
      error:  'validation_error',
      errors: result.error.flatten().fieldErrors,
      // Example:
      // { name: ['String must contain at least 1 character(s)'],
      //   email: ['Invalid email'] }
    })
  }

  // result.data is typed as CreateUserBody — guaranteed valid
  const { name, email, age, role } = result.data
  res.status(201).json({ id: 1, name, email, age, role })
})

// ── Reusable validate 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(422).json({
        error:  'validation_error',
        errors: result.error.flatten().fieldErrors,
      })
    }
    // Replace req.body with the parsed, type-safe result
    req.body = result.data
    next()
  }
}

// ── Use validate middleware on routes ─────────────────────────
app.post('/users', validate(CreateUserSchema), (req: Request, res: Response) => {
  // req.body is now typed and validated — safe to use
  res.status(201).json({ id: 2, ...req.body })
})

app.patch('/users/:id', validate(UpdateUserSchema), async (req: Request, res: Response) => {
  const updated = await db.updateUser(req.params.id, req.body)
  res.json(updated)
})

// ── Validating query params with Zod ─────────────────────────
const ListUsersQuerySchema = z.object({
  page:   z.coerce.number().int().min(1).default(1),
  limit:  z.coerce.number().int().min(1).max(100).default(20),
  search: z.string().optional(),
})

app.get('/users', (req: Request, res: Response) => {
  const result = ListUsersQuerySchema.safeParse(req.query)
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten().fieldErrors })
  }
  const { page, limit, search } = result.data
  // page is number (coerced from query string), limit is number
  res.json({ page, limit, search, users: [] })
})

The validate middleware factory is the cleanest way to apply Zod validation across multiple routes without repeating the safeParse + error response logic. By replacing req.body with result.data inside the middleware, downstream handlers receive the parsed, validated value with all of Zod's transforms applied (coercion, defaults, etc.). For query parameter validation, use z.coerce.number() because all query string values arrive as strings in Express regardless of their semantic type.

TypeScript Types for req.body in Express

Express's TypeScript definitions type req.body as any by default. There are three approaches to adding type safety: generic Request type parameters, module augmentation for global type overrides, and Zod-inferred types with runtime validation. The third approach is the most correct because it combines compile-time TypeScript types with runtime shape guarantees.

import express, { Request, Response } from 'express'
import { z } from 'zod'

// ── Approach 1: Generic Request type parameters ────────────────
// Request<Params, ResBody, ReqBody, Query>
interface CreatePostBody {
  title:   string
  content: string
  tags?:   string[]
}

app.post(
  '/posts',
  (req: Request<{}, {}, CreatePostBody>, res: Response) => {
    // req.body is typed as CreatePostBody
    // BUT: no runtime validation — client can send anything
    const { title, content } = req.body
    res.status(201).json({ id: 1, title, content })
  }
)

// ── Approach 2: Module augmentation ───────────────────────────
// Extend Express Request for cross-cutting concerns (e.g. auth)
declare global {
  namespace Express {
    interface Request {
      user?: {
        id:    string
        email: string
        role:  'user' | 'admin'
      }
    }
  }
}

// Auth middleware populates req.user
function authMiddleware(req: Request, res: Response, next: express.NextFunction) {
  const token = req.headers.authorization?.replace('Bearer ', '')
  if (!token) return res.status(401).json({ error: 'unauthorized' })
  req.user = { id: '42', email: 'alice@example.com', role: 'user' } // from JWT
  next()
}

app.get('/profile', authMiddleware, (req: Request, res: Response) => {
  // req.user is typed via module augmentation — no casting needed
  res.json(req.user)
})

// ── Approach 3: Zod inference (recommended) ───────────────────
const CreatePostSchema = z.object({
  title:   z.string().min(1).max(200),
  content: z.string().min(10),
  tags:    z.array(z.string()).max(10).default([]),
})

// Infer TypeScript type from schema — stays in sync automatically
type CreatePost = z.infer<typeof CreatePostSchema>

app.post('/posts', (req: Request, res: Response) => {
  // req.body starts as any
  const result = CreatePostSchema.safeParse(req.body)

  if (!result.success) {
    return res.status(422).json({ errors: result.error.flatten().fieldErrors })
  }

  // result.data is fully typed as CreatePost — guaranteed at runtime
  const post: CreatePost = result.data
  // post.title, post.content, post.tags are all typed and validated

  res.status(201).json({ id: 10, ...post })
})

// ── Typed error handler ────────────────────────────────────────
interface ApiError {
  error:   string
  message: string
  errors?: Record<string, string[]>
}

app.use((err: any, req: Request, res: Response<ApiError>, next: express.NextFunction) => {
  res.status(err.status ?? 500).json({
    error:   err.code ?? 'server_error',
    message: err.message ?? 'An error occurred',
  })
})

Module augmentation (Approach 2) is the correct pattern for adding properties to req that are set by middleware and consumed by downstream handlers — authentication user objects, request IDs, tenant context. It avoids casting (req as any).user throughout the codebase. Combine all three approaches in a real application: use Zod schemas for request body types (Approach 3), module augmentation for middleware-added properties (Approach 2), and the generic Request type for one-off route annotations (Approach 1) where a full Zod schema is overkill.

FAQ

Why is req.body undefined in Express?

req.body is undefined when the express.json() middleware has not been registered before the route handler that reads it. Express does not parse request bodies automatically — you must call app.use(express.json()) at the application level, or pass it as a route-level middleware argument, before any handler that accesses req.body. This became a built-in middleware in Express 4.16.0, released in September 2017, so no external package is needed on any modern Express version. The second most common cause is registering app.use(express.json()) after the route definition — middleware runs in declaration order, so a route defined before the middleware never sees a parsed body. A third cause: the client sent the request without the Content-Type: application/json header. express.json() only parses bodies whose Content-Type matches application/json; requests with no Content-Type or with application/x-www-form-urlencoded leave req.body undefined for the JSON middleware.

How do I send a JSON response in Express?

Call res.json(data) to serialize any JavaScript value to JSON, set the Content-Type: application/json header automatically, and send the response. res.json() internally calls JSON.stringify() with the app's configured replacer and spaces settings. To include an HTTP status code, chain res.status(code) before res.json(): res.status(201).json({'{ id: newUser.id }'}) sets status 201 Created and sends the JSON body. status() must be called before json() — they cannot be reversed. res.json() accepts any JSON-serializable value: objects, arrays, strings, numbers, booleans, and null. For error responses, use res.status(400).json({'{ message: "Bad Request" }'}) or a structured format like res.status(422).json({'{ errors: validationErrors }'}). Express also provides res.jsonp() for JSONP callbacks, but JSONP is legacy — use CORS instead for cross-origin requests.

How do I set the body size limit for JSON in Express?

Pass a limit option to express.json(): app.use(express.json({'{ limit: \'10mb\' }'})). The default limit is 100kb. The value accepts a string with a unit suffix (b, kb, mb, gb) or a raw number of bytes — for example, '2mb', '500kb', or 2097152 (2 MB as bytes). When a request body exceeds the limit, Express responds with HTTP 413 Payload Too Large. You can catch this in an error-handling middleware by checking err.type === 'entity.too.large' or err.status === 413. For most REST APIs, the default 100kb limit is appropriate — raising it to 10mb or more is typically only needed for endpoints that accept base64-encoded file data. Keep the limit as low as practical to reduce memory pressure and protect against denial-of-service attacks.

How do I handle JSON parse errors in Express?

JSON parse errors from malformed request bodies are passed to Express error-handling middleware as an error object with err.type === 'entity.parse.failed' and err.status === 400. Define a 4-parameter error handler — function(err, req, res, next) — after all routes to catch them: if (err.type === 'entity.parse.failed') return res.status(400).json({ message: 'Invalid JSON' }). The error handler must have exactly 4 parameters for Express to treat it as an error handler rather than a regular middleware. Register it after all route definitions, as the last app.use() call. Without an error handler, Express returns a plain-text 400 error with the full stack trace visible to clients — a security issue in production. Always respond with a clean JSON error object rather than forwarding err.message directly.

What is the difference between res.json() and res.send() in Express?

res.json() is a superset of res.send() for JSON data. Both send a response body, but res.json() always sets Content-Type: application/json and calls JSON.stringify() on the argument, while res.send() infers the Content-Type from the argument type: a string becomes text/html, a Buffer becomes application/octet-stream, and an object or array becomes application/json (with JSON.stringify applied). For JSON API endpoints, always use res.json() — it is explicit, avoids type inference surprises, and uses the app's configured json replacer and json spaces settings. One practical difference: res.json(null) sends the literal JSON string null with Content-Type: application/json, which is valid JSON. Another: res.json() respects app.set('json spaces', 2) for pretty-printing, while res.send(obj) uses a compact default. Use res.send() for non-JSON responses (HTML, plain text, binary) and res.json() for all JSON API responses.

How do I validate JSON request bodies in Express?

The recommended pattern is Zod with safeParse() in the route handler or a reusable validate middleware. Define a Zod schema for the expected body shape: const Schema = z.object({ name: z.string().min(1), email: z.string().email() }). In the handler, call Schema.safeParse(req.body). If result.success is false, return res.status(422).json({'{ errors: result.error.flatten().fieldErrors }'}). If true, use result.data — fully typed and sanitized by Zod. For reuse, wrap in a middleware factory: function validate(schema) { return (req, res, next) => { const r = schema.safeParse(req.body); if (!r.success) return res.status(422).json({ errors: r.error.flatten().fieldErrors }); req.body = r.data; next(); }; }. Alternative libraries include Joi (24 kB minzipped, older API), express-validator (declarative chain syntax), and Ajv for JSON Schema-based validation (fastest at 90k+ ops/sec).

How do I type req.body in TypeScript with Express?

By default, Express types req.body as any. To add type safety, use the generic Request type: (req: Request<{}, {}, CreateUserBody>, res: Response) — the third generic parameter is the body type. However, the cleanest approach for runtime safety is to validate with Zod and assign the typed result: const result = Schema.safeParse(req.body); if (result.success) { const body: z.infer<typeof Schema> = result.data; }. This combines compile-time typing with runtime validation — TypeScript knows the shape and Zod ensures the shape at runtime. For middleware-injected properties (like an authenticated user), use module augmentation: declare global { namespace Express { interface Request { user?: User } } }. Avoid req.body as SomeType casts without validation — they are lies to the TypeScript compiler with zero runtime safety.

How do I pretty-print JSON responses in Express?

Set app.set('json spaces', 2) to make all res.json() calls output JSON with 2-space indentation. This applies globally to every route in the application. The json spaces setting accepts any value accepted by JSON.stringify's third argument: a number of spaces (0–10) or a string like '\t' for tab indentation. By default, Express uses 0 spaces (compact, no whitespace) in production. Enable pretty-printing in development only: if (process.env.NODE_ENV !== 'production') app.set('json spaces', 2). You can also override the JSON serializer globally with app.set('json replacer', fn) to filter or transform values during serialization — useful for removing undefined values, replacing Date objects with ISO strings, or masking sensitive fields. These settings only affect res.json() calls — JSON.stringify() calls elsewhere in the code are not affected.

Format and validate JSON instantly

Paste any JSON into Jsonic's formatter to validate, pretty-print, and explore your Express API payloads without writing any code.

Open JSON Formatter

Further reading and primary sources