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/:id → req.params.id |
| URL query string | req.query |
/search?q=node → req.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 whoseContent-Typeisapplication/json. Useful options:limit(max body size, default"100kb") andstrict(defaulttrue, only accepts arrays/objects, not bare strings or numbers).express.urlencoded({ extended })— parses HTML form submissions.extended: trueusesqsand supports nested objects/arrays;extended: falseuses Node’squerystringand 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-Typeheader against the typesexpress.json()is configured to match. If it does not match, the middleware callsnext()right away and does nothing else. - If it matches, the middleware attaches listeners to the request stream (
dataandend) 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
limitoption while buffering; if the accumulated size exceeds it, it aborts and produces an error witherr.type === "entity.too.large"instead of continuing to buffer. - Once the stream emits
end, the middleware has the complete raw body as aBuffer. It runsJSON.parse()on it. If parsing succeeds, it assigns the result toreq.bodyand callsnext()with no arguments, moving on to your route handler. If parsing throws, it callsnext(err)witherr.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()andexpress.urlencoded()once, near the top of your app, before your routes — they are cheap no-ops for requests whoseContent-Typedoesn’t match. - Always set a sensible
limitoption onexpress.json()/express.urlencoded()to prevent a client from sending an oversized body that wastes memory and CPU. - Never trust
req.bodyorreq.queryas-is — validate types and required fields before using them, ideally with a schema library likezodfor 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
multerwith 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 /loginroute that readsemailandpasswordfrom a JSON body. Respond with400and a JSON error if either field is missing or not a string; otherwise respond200with{ ok: true }. - Add a
GET /searchroute that reads aqquery parameter and an optionalpagequery parameter. Defaultpageto1when absent, coerce it to a number, and respond with{ q, page }. Test what happens whenpageis a non-numeric string like"abc", and add a check that falls back to1in that case too. - Extend Example 3’s notes API with a
PATCH /notes/:idroute that reads a JSON body containing an optionaltitleand/orbody, updates only the fields provided, and returns404if no note with that id exists.
Summary
req.queryis always parsed automatically from the URL; request bodies require explicit middleware such asexpress.json()orexpress.urlencoded().- Body-parsing middleware checks the
Content-Typeheader 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; usemulterfor 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 parsersetting issimple, notextended; switch it withapp.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 clean400instead of a crash. - Always validate
req.bodyandreq.querybefore trusting them — nothing on the client guarantees the shape or type of what it sends.
