Parsing Request Bodies and Queries

Every real API needs to read data the client sends: a JSON payload in a POST request, a form submission, or filters passed in the URL as a query string. Express does not parse any of this automatically — you have to tell it what kinds of bodies to expect and where to find them. This lesson covers express.json(), express.urlencoded(), and req.query in depth, including how they work under the hood, how they fail, and how to use them safely in a real API.

Overview: How Express Parses Incoming Data

An HTTP request carries data in three separate places, and Express exposes each one through a different property on req:

Where Express property Example
URL path segments req.params /users/:idreq.params.id
URL query string req.query /search?q=nodereq.query.q
Request body req.body JSON or form data sent with POST/PUT/PATCH

This lesson focuses on the last two: bodies and queries. req.query is always available — Express parses the query string for every request whether or not you asked it to, because the query string is just part of the URL and costs nothing to read. The request body, on the other hand, arrives as a stream of raw bytes on the underlying Node http.IncomingMessage, and Node does not buffer or parse it for you. Express needs a piece of middleware to read that stream, buffer it, and turn it into a usable JavaScript value on req.body.

Since Express 4.16, the two most common body parsers ship built into the framework itself — you no longer need the separate body-parser package for JSON or URL-encoded bodies:

Middleware Matches Content-Type req.body becomes
express.json() application/json a parsed JavaScript object/array
express.urlencoded({ extended: true }) application/x-www-form-urlencoded a parsed object (supports nested keys via qs)
express.text() text/plain a raw string
express.raw() application/octet-stream a Buffer

Each of these middleware functions checks the request’s Content-Type header before doing any work. If the header does not match, the middleware calls next() immediately and leaves req.body untouched (it defaults to {} once any body parser has run, or undefined if none has). That means it is completely safe to mount several of them globally — each request only pays the cost of the parser that actually matches its content type.

None of these middleware functions handle multipart/form-data (the encoding used for file uploads and HTML forms with file inputs). Express has no built-in multipart parser; you need a separate package such as multer for that.

A note on query parsing in Express 5

Query strings are parsed according to the app’s query parser setting. Express 5 changed the default value of this setting from extended to simple, which uses Node’s built-in querystring module instead of the third-party qs library. Under the default simple parser, repeating a key (?tags=js&tags=web) still produces an array, but bracket notation for nested objects (?user[name]=Ada) is not parsed into a nested object — you get a flat string key instead. If you rely on nested query objects, opt back in with app.set("query parser", "extended").

Syntax

app.use(express.json());                          // parses application/json into req.body
app.use(express.urlencoded({ extended: true }));  // parses form posts into req.body

app.get("/route", (req, res) => {
  req.query; // parsed query-string parameters (always strings/arrays)
  req.body;  // parsed request body, populated by the middleware above
});
  • express.json(options) — parses bodies whose Content-Type is application/json. Useful options: limit (max body size, default "100kb") and strict (default true, only accepts arrays/objects, not bare strings or numbers).
  • express.urlencoded({ extended }) — parses HTML form submissions. extended: true uses qs and supports nested objects/arrays; extended: false uses Node’s querystring and only supports flat key/value pairs. Always pass this option explicitly.
  • req.query — a plain object built from everything after the ? in the URL. Every value is a string, an array of strings, or (with the extended parser) a nested object — never a number or boolean.

Examples

Example 1: Parsing JSON and URL-Encoded Bodies

This server mounts both built-in parsers globally and echoes back whatever body it received, regardless of whether the client sent JSON or a form-encoded submission.

import express from "express";

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: true }));

app.post("/echo", (req, res) => {
  res.status(200).json({ received: req.body });
});

const port = process.env.PORT ?? 3000;
app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});
curl -X POST http://localhost:3000/echo \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada","role":"engineer"}'

curl -X POST http://localhost:3000/echo \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "name=Ada&role=engineer"

Both requests return the same shape, {"received":{"name":"Ada","role":"engineer"}}, even though one sent JSON and the other sent a URL-encoded form body. That is the point of mounting both parsers: each request is handled by whichever one matches its Content-Type, and the route handler does not need to care which format arrived.

Example 2: Reading Query String Parameters

Query parameters never need a body parser — they are already available on every request. This route reads a search term, a page number, and a possibly-repeated tags parameter.

import express from "express";

const app = express();

app.get("/search", (req, res) => {
  const { q, page, tags } = req.query;

  res.json({
    query: q ?? "",
    page: Number(page ?? 1),
    tags: Array.isArray(tags) ? tags : tags ? [tags] : [],
  });
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});
curl "http://localhost:3000/search?q=node&page=2&tags=js&tags=web"

Output: {"query":"node","page":2,"tags":["js","web"]}. Notice two things: page arrives as the string "2" and has to be explicitly converted with Number(), and repeating tags in the URL automatically becomes an array — you do not need any extra syntax for that case.

Example 3: A Realistic Notes API with Validation and Error Handling

A more complete example that combines body parsing, query-based filtering, input validation, and an error-handling middleware that catches malformed JSON.

import express from "express";

const app = express();
app.use(express.json({ limit: "100kb" }));

const notes = [];
let nextId = 1;

app.post("/notes", (req, res) => {
  const { title, body } = req.body ?? {};

  if (typeof title !== "string" || title.trim() === "") {
    return res.status(400).json({ error: "title is required" });
  }

  const note = { id: nextId++, title, body: body ?? "", createdAt: new Date().toISOString() };
  notes.push(note);
  res.status(201).json(note);
});

app.get("/notes", (req, res) => {
  const limit = Math.min(Number(req.query.limit ?? 10), 50);
  const search = typeof req.query.search === "string" ? req.query.search.toLowerCase() : "";

  const results = notes
    .filter((note) => note.title.toLowerCase().includes(search))
    .slice(0, limit);

  res.json({ count: results.length, results });
});

app.use((err, req, res, next) => {
  if (err.type === "entity.parse.failed") {
    return res.status(400).json({ error: "Malformed JSON body" });
  }
  console.error(err);
  res.status(500).json({ error: "Internal server error" });
});

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});
curl -X POST http://localhost:3000/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"Buy milk","body":"2% please"}'

curl "http://localhost:3000/notes?search=milk&limit=5"

curl -X POST http://localhost:3000/notes \
  -H "Content-Type: application/json" \
  -d '{"title": "bad json"'

The first call returns 201 with the created note. The second returns {"count":1,"results":[...]} because search matches the note’s title. The third call sends deliberately broken JSON (a missing closing brace); express.json() fails to parse it and passes an error to next(err), which the 4-argument error-handling middleware at the bottom catches, responding 400 {"error":"Malformed JSON body"} instead of crashing the process.

How It Works Step by Step

When a request with a JSON body hits a route protected by express.json(), the following happens in order:

  • Express checks the request’s Content-Type header against the types express.json() is configured to match. If it does not match, the middleware calls next() right away and does nothing else.
  • If it matches, the middleware attaches listeners to the request stream (data and end) and starts buffering incoming chunks. This is asynchronous — the body may arrive over several TCP packets, and Node delivers it in pieces.
  • The middleware also enforces the limit option while buffering; if the accumulated size exceeds it, it aborts and produces an error with err.type === "entity.too.large" instead of continuing to buffer.
  • Once the stream emits end, the middleware has the complete raw body as a Buffer. It runs JSON.parse() on it. If parsing succeeds, it assigns the result to req.body and calls next() with no arguments, moving on to your route handler. If parsing throws, it calls next(err) with err.type === "entity.parse.failed", skipping straight past your normal route handlers to the nearest error-handling middleware.
  • Your route handler only ever runs once the full body has been read and parsed — you never see partial data.

req.query, in contrast, needs none of this: Express parses the query string synchronously from the URL as part of building the request object, before any middleware runs, because the entire query string is already present in the first line of the HTTP request. That is why req.query works even in a bare Express app with no body-parsing middleware mounted at all.

Common Mistakes

Mistake 1: Forgetting to mount a body parser. Without it, req.body is undefined, and destructuring it throws.

import express from "express";
const app = express();

app.post("/users", (req, res) => {
  console.log(req.body); // undefined - no body parser mounted
  res.sendStatus(204);
});

Fix: mount app.use(express.json()) (and express.urlencoded if you also accept form posts) before your routes are defined.

Mistake 2: Expecting express.json() to handle file uploads. Multipart form data is a different encoding entirely; the built-in parsers silently skip it.

app.post("/upload", (req, res) => {
  console.log(req.body); // {} - multipart bodies are not parsed by express.json()
  console.log(req.files); // undefined - Express has no built-in multipart support
  res.sendStatus(204);
});

Fix: install and use multer (npm install multer) as dedicated multipart middleware for routes that accept file uploads.

Mistake 3: Treating query values as typed data. Every value in req.query is a string (or an array of strings) — never a number or a boolean, even if it looks like one in the URL.

app.get("/products", (req, res) => {
  const { inStock, limit } = req.query;

  if (inStock === true) { // never true - query values are always strings
    // ...
  }

  const results = products.slice(0, limit); // limit is a string like "10", not a number
  res.json(results);
});

Corrected version, converting explicitly:

app.get("/products", (req, res) => {
  const inStock = req.query.inStock === "true";
  const limit = Number(req.query.limit ?? 10);

  const results = products.slice(0, limit);
  res.json({ inStock, results });
});

Mistake 4: Not handling JSON parse errors. If you never add an error-handling middleware, a malformed JSON body crashes the request with Express’s default HTML error page (or, worse, an unhandled error in your logs) instead of a clean JSON 400 response, as shown in Example 3.

Best Practices

  • Only mount express.json() and express.urlencoded() once, near the top of your app, before your routes — they are cheap no-ops for requests whose Content-Type doesn’t match.
  • Always set a sensible limit option on express.json()/express.urlencoded() to prevent a client from sending an oversized body that wastes memory and CPU.
  • Never trust req.body or req.query as-is — validate types and required fields before using them, ideally with a schema library like zod for anything beyond a trivial check.
  • Add a 4-argument error-handling middleware at the end of your middleware stack to catch parse failures and return a clean JSON error instead of leaking a stack trace.
  • Use express.urlencoded({ extended: true }) unless you have a specific reason to disable nested parsing — passing the option explicitly (rather than omitting it) makes the behavior clear to future readers.
  • For file uploads, use multer with an explicit file size limit rather than accepting unlimited multipart bodies.
  • Remember that query values are always strings/arrays — convert and validate numbers, booleans, and dates explicitly.

Practice Exercises

  • Build a POST /login route that reads email and password from a JSON body. Respond with 400 and a JSON error if either field is missing or not a string; otherwise respond 200 with { ok: true }.
  • Add a GET /search route that reads a q query parameter and an optional page query parameter. Default page to 1 when absent, coerce it to a number, and respond with { q, page }. Test what happens when page is a non-numeric string like "abc", and add a check that falls back to 1 in that case too.
  • Extend Example 3’s notes API with a PATCH /notes/:id route that reads a JSON body containing an optional title and/or body, updates only the fields provided, and returns 404 if no note with that id exists.

Summary

  • req.query is always parsed automatically from the URL; request bodies require explicit middleware such as express.json() or express.urlencoded().
  • Body-parsing middleware checks the Content-Type header and only acts on matching requests, so it’s safe to mount several parsers globally.
  • Express has no built-in support for multipart/form-data; use multer for file uploads.
  • Query values are always strings or arrays of strings — convert them explicitly before using them as numbers or booleans.
  • Express 5’s default query parser setting is simple, not extended; switch it with app.set("query parser", "extended") if you need bracket-notation nested query objects.
  • Handle JSON parse failures with an error-handling middleware that checks err.type === "entity.parse.failed" so bad input returns a clean 400 instead of a crash.
  • Always validate req.body and req.query before trusting them — nothing on the client guarantees the shape or type of what it sends.