🔎 Query Parameters & Body Parsing
A request carries data in more than one pocket. The URL path names which resource, the query string refines how you want it, and the body carries the bulk payload. Learn to reach into each pocket correctly and you can build search, filtering, pagination, and forms with confidence.
Week 7 · Day 4 (Thursday: Working with Data) · Lecture 2
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish route params (
req.params) from query params (req.query) and know when to use each - Read query parameters and remember that every value arrives as a string
- Safely parse and validate numeric query params for pagination, filtering, and sorting
- Handle repeated and array-style query parameters without crashing
- Parse request bodies with
express.json()andexpress.urlencoded() - Explain what
extended: truedoes for URL-encoded form data
Estimated Time: 60 minutes
Practice: Build a paginated, filterable, sortable articles endpoint.
In This Lesson
Where Request Data Lives
Every incoming HTTP request is a little parcel with several compartments. Express exposes each one on the req object. Knowing which compartment holds what is the difference between code that "just works" and an afternoon of confused logging.
req.params"] A --> C["Query string
req.query"] A --> D["Request body
req.body"] A --> E["Headers
req.headers"] A --> F["Cookies
req.cookies"]
A useful analogy: imagine walking up to a library desk.
- Route params are like naming the exact book — "I want book #123." Built into the address itself.
- Query params are the follow-up refinements — "sorted by author, only the ones from this year." Optional tweaks to the main request.
- Request body is handing over a filled-in form — a chunk of structured data too big for the address line.
- Headers are the context — who you are, what format you can read.
- Cookies are the librarian remembering you from last time.
This lesson focuses on the two you'll touch most: query params and the body.
Route Params vs Query Params
Both live in the URL, and beginners mix them up constantly. Look at where each sits in the address:
key=value pairs after the ?.// Route param: declared with a colon in the path
app.get('/users/:id', (req, res) => {
res.json({ requestedId: req.params.id }); // "123" (always a string)
});
// Query params: no special route setup needed — Express parses them for you
app.get('/users', (req, res) => {
res.json({ query: req.query }); // { sort: 'name', order: 'asc' }
});
| Feature | Route Params | Query Params |
|---|---|---|
| Looks like | /users/:id | /users?id=123 |
| Best for | Identifying a specific resource | Filtering, sorting, pagination, search |
| Typically | Required | Optional |
| Accessed via | req.params.id | req.query.id |
| Route setup | Declared explicitly with : | Automatic — no setup |
💡 Rule of thumb: if the value names the thing you're fetching, it's a route param. If it modifies how you fetch a collection, it's a query param.
Reading Query Parameters
Express parses the query string into the req.query object automatically — no middleware required. Given the URL /api/products?category=electronics&sort=price&minPrice=100:
app.get('/api/products', (req, res) => {
console.log(req.query);
// { category: 'electronics', sort: 'price', minPrice: '100' }
const { category, sort, minPrice } = req.query;
console.log(category); // 'electronics'
console.log(minPrice); // '100' ← a STRING, not the number 100!
res.json({ received: req.query });
});
⚠️ Everything in req.query is a string
This is the single most common query-param bug. minPrice above is the string '100', not the number 100. So req.query.minPrice > 50 and req.query.page + 1 will misbehave ('100' + 1 === '1001'!). You must convert numbers and booleans yourself.
A few more things to keep in mind about req.query:
- A missing parameter is
undefined, nevernull. - Key names are case-sensitive:
?Page=2is not?page=2. - A repeated key like
?tag=a&tag=bcan arrive as an array — more on that shortly. - The values are URL-decoded for you, so
?q=hello%20worldbecomes'hello world'.
Parsing & Validating Query Params
Because query values are strings, the pattern for numbers is always: parse, then validate. Never trust that the client sent a sensible value.
app.get('/api/products', (req, res) => {
const { category, minPrice, maxPrice, inStock, page, limit } = req.query;
// Parse into real types, with sensible defaults
const filters = {
category, // keep as string
minPrice: minPrice !== undefined ? Number(minPrice) : undefined,
maxPrice: maxPrice !== undefined ? Number(maxPrice) : undefined,
inStock: inStock === 'true', // string → boolean
page: Number.parseInt(page ?? '1', 10), // default page 1
limit: Number.parseInt(limit ?? '20', 10) // default 20 per page
};
// Validate AFTER parsing — Number('abc') is NaN
if (filters.minPrice !== undefined && Number.isNaN(filters.minPrice)) {
return res.status(400).json({ error: 'minPrice must be a number' });
}
if (!Number.isInteger(filters.page) || filters.page < 1) {
return res.status(400).json({ error: 'page must be a positive integer' });
}
if (!Number.isInteger(filters.limit) || filters.limit < 1 || filters.limit > 100) {
return res.status(400).json({ error: 'limit must be between 1 and 100' });
}
res.json({ appliedFilters: filters });
});
📖 Number() vs parseInt() vs parseFloat()
Number('12px') is NaN — it demands the whole string be numeric, which makes it stricter and safer. parseInt('12px', 10) is 12 — it reads as far as it can. Use parseInt(x, 10) for whole numbers (always pass the radix 10!), parseFloat for decimals, and reach for Number() when you want a strict all-or-nothing conversion.
✅ Clamp instead of reject where it helps UX
For a page size, rejecting limit=99999 with a 400 is defensible — but silently clamping to your max is often friendlier: const limit = Math.min(Math.max(parsed, 1), 100);. Choose per endpoint; just never let an unbounded value reach your database.
Array & Repeated Params
Query strings can express lists, and clients do it in several styles. Express (via its default qs parser) interprets them like this:
| Query string | req.query.color becomes |
|---|---|
?color=red | 'red' (a string) |
?color=red&color=blue | ['red', 'blue'] (an array) |
?color[]=red&color[]=blue | ['red', 'blue'] (an array) |
?color=red,blue | 'red,blue' (one string — you split it) |
The gotcha: the same key can arrive as a string or an array depending on how many values the client sent. Normalize it to always be an array before you use it:
app.get('/api/products', (req, res) => {
const raw = req.query.color;
let colors;
if (Array.isArray(raw)) {
colors = raw; // already ['red', 'blue']
} else if (typeof raw === 'string') {
colors = raw.split(','); // 'red,blue' → ['red', 'blue']
} else {
colors = []; // undefined → empty list
}
res.json({ colors }); // consistently an array, no matter the input style
});
💡 A tidy one-liner: const colors = [].concat(req.query.color ?? []).flatMap(c => String(c).split(',')); handles the string, array, and missing cases together — but the explicit version above is easier to read and debug while you're learning.
Pagination, Filtering & Sorting
These three patterns power almost every "list" endpoint. Let's build a paginated articles endpoint end to end.
app.get('/api/articles', (req, res) => {
// 1. Parse pagination params with defaults
const page = Number.parseInt(req.query.page ?? '1', 10);
const limit = Number.parseInt(req.query.limit ?? '10', 10);
// 2. Validate
if (!Number.isInteger(page) || page < 1) {
return res.status(400).json({ error: 'page must be a positive integer' });
}
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
return res.status(400).json({ error: 'limit must be between 1 and 100' });
}
// 3. Optional sort (whitelist the allowed fields — never trust raw input!)
const allowedSorts = ['title', 'createdAt', 'views'];
const sort = allowedSorts.includes(req.query.sort) ? req.query.sort : 'createdAt';
const order = req.query.order === 'asc' ? 'asc' : 'desc';
// 4. Mock data source
let articles = Array.from({ length: 95 }, (_, i) => ({
id: i + 1,
title: `Article ${i + 1}`,
views: Math.floor(Math.random() * 1000),
createdAt: new Date(2026, 0, i + 1).toISOString()
}));
// 5. Sort
articles.sort((a, b) => {
const dir = order === 'asc' ? 1 : -1;
return a[sort] > b[sort] ? dir : a[sort] < b[sort] ? -dir : 0;
});
// 6. Slice to the current page
const total = articles.length;
const start = (page - 1) * limit;
const pageItems = articles.slice(start, start + limit);
// 7. Respond with data + metadata the client needs to build controls
res.json({
data: pageItems,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
hasNext: start + limit < total,
hasPrev: page > 1
}
});
});
⚠️ Whitelist your sort field
Notice we check allowedSorts.includes(req.query.sort) instead of using the raw value. Passing an arbitrary user-supplied field straight into a database sort is a classic injection vector. Always sort by a value from a fixed allow-list.
Returning pagination metadata alongside the data is what lets a front-end render "Page 2 of 10" and enable/disable its Next button. Give the client everything it needs in one response.
Body Parsing
Query params are great for small, optional refinements. For substantial data — creating a user, submitting an order — you use the request body. Unlike the query string, Express does not parse bodies automatically; you register the matching middleware.
with a body"] --> B{"Content-Type?"} B -->|application/json| C["express.json()"] B -->|form-urlencoded| D["express.urlencoded()"] C --> E["req.body"] D --> E E --> F["Route handler"]
const express = require('express');
const app = express();
// Two built-in parsers, registered once near the top:
app.use(express.json()); // for application/json
app.use(express.urlencoded({ extended: true })); // for HTML form posts
app.post('/api/users', (req, res) => {
const { username, email, age } = req.body;
if (!username || !email) {
return res.status(400).json({ error: 'username and email are required' });
}
res.status(201).json({ message: 'User created', user: { username, email, age } });
});
Each parser only activates when the request's Content-Type matches, so it's safe to register both. A JSON request flows through express.json(); a form submission flows through express.urlencoded(); each leaves the result on req.body.
💡 Parser options you'll actually use
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({
extended: true, // allow nested objects/arrays (uses the qs library)
limit: '1mb', // cap the body size
parameterLimit: 1000 // max number of form fields
}));
URL-encoded Form Data
When a classic HTML <form> submits without JavaScript, the browser encodes the fields as application/x-www-form-urlencoded — the same key=value&key=value format as a query string, but in the body. express.urlencoded() parses it onto req.body.
app.use(express.urlencoded({ extended: true }));
app.post('/login', (req, res) => {
const { username, password, rememberMe } = req.body;
// Checkboxes are quirky: present as 'on' when checked, absent otherwise
const shouldRemember = rememberMe === 'on';
if (username === 'ada' && password === 'secret123') {
return res.redirect('/dashboard');
}
res.redirect('/login?error=invalid_credentials');
});
What extended actually controls
The extended option decides which library parses the body, and that changes how nested field names are interpreted:
<!-- A form with nested field names -->
<form action="/api/profile" method="POST">
<input name="user[name]" value="John Doe">
<input name="user[email]" value="john@example.com">
<input name="hobbies[]" value="reading">
<input name="hobbies[]" value="hiking">
</form>
| Setting | Resulting req.body |
|---|---|
extended: true(the qs library) | { user: { name: 'John Doe', email: 'john@example.com' }, hobbies: ['reading', 'hiking'] } |
extended: false(the built-in querystring) | { 'user[name]': 'John Doe', 'user[email]': 'john@example.com', 'hobbies[]': ['reading', 'hiking'] } |
💡 Which do I pick? Useextended: truewhen your forms send nested structures (most modern apps). Useextended: falsefor strictly flat forms and slightly less overhead. When unsure,trueis the friendlier default.
⚠️ File uploads are a different beast
A form with enctype="multipart/form-data" (used for file inputs) is not parsed by express.urlencoded(). Those need dedicated middleware — which is exactly the topic of the next lesson, Multer.
Practice & Quiz
🏋️ Exercise 1: Safe pagination helper
Goal: Write getPagination(query) that reads page and limit from a query object, defaults them to 1 and 20, clamps limit to a maximum of 100 and a minimum of 1, forces page to at least 1, and returns { page, limit, offset } where offset = (page - 1) * limit.
function getPagination(query) {
// TODO: parse, default, clamp, and compute offset
}
getPagination({ page: '3', limit: '25' }); // { page: 3, limit: 25, offset: 50 }
getPagination({}); // { page: 1, limit: 20, offset: 0 }
getPagination({ page: '-2', limit: '999' });// { page: 1, limit: 100, offset: 0 }
💡 Hint
Parse with Number.parseInt(x ?? default, 10). Guard against NaN by falling back to the default. Clamp with Math.min/Math.max.
✅ Solution
function getPagination(query) {
let page = Number.parseInt(query.page ?? '1', 10);
let limit = Number.parseInt(query.limit ?? '20', 10);
if (!Number.isInteger(page)) page = 1;
if (!Number.isInteger(limit)) limit = 20;
page = Math.max(page, 1);
limit = Math.min(Math.max(limit, 1), 100);
return { page, limit, offset: (page - 1) * limit };
}
🏋️ Exercise 2: Multi-filter search
Goal: Write GET /api/users that filters an in-memory array by optional role and status query params, and by a case-insensitive search that matches the user's name. Return the filtered count and data.
✅ Solution
app.get('/api/users', (req, res) => {
const { search, role, status } = req.query;
let users = [
{ id: 1, name: 'Alice Johnson', role: 'admin', status: 'active' },
{ id: 2, name: 'Bob Smith', role: 'user', status: 'inactive' },
{ id: 3, name: 'Carol Williams',role: 'editor', status: 'active' }
];
if (role) users = users.filter(u => u.role === role);
if (status) users = users.filter(u => u.status === status);
if (search) {
const q = search.toLowerCase();
users = users.filter(u => u.name.toLowerCase().includes(q));
}
res.json({ count: users.length, data: users });
});
Each filter is applied only when its param is present, so any combination works — including none, which returns everyone.
🎯 Quick Quiz
Question 1: For the URL /products?page=2, what is the type and value of req.query.page?
Question 2: Which value belongs in a route param rather than a query param?
Question 3: With express.urlencoded({ extended: true }), a field named user[name] parses into:
Best Practices & Pitfalls
✅ Do
- Treat every
req.queryvalue as a string — parse numbers and booleans yourself - Validate after parsing, checking for
NaNand out-of-range values - Provide defaults for optional params (page, limit, sort order)
- Whitelist sort fields and enum-like values instead of trusting raw input
- Return pagination metadata so clients can build their own controls
❌ Don't
- Do math on a query param without converting it (
'100' + 1 === '1001') - Assume a repeated param is always a string — it may be an array
- Let
limitreach your database unbounded (cap it!) - Forget
express.urlencoded()and wonder why formreq.bodyis empty - Use
parseInt(x)without the radix10
⚠️ The string-math trap in one place
// ❌ req.query.page is a string
const next = req.query.page + 1; // '2' + 1 → '21'
// ✅ parse first
const page = Number.parseInt(req.query.page ?? '1', 10);
const next = page + 1; // 3
Summary
🎉 Key Takeaways
- Route params (
req.params) name a resource; query params (req.query) refine a collection - Every query value is a string — always parse and validate before use
- Repeated keys can arrive as arrays — normalize before iterating
- Pagination, filtering, and sorting are just parsed-and-validated query params plus response metadata
- Register
express.json()andexpress.urlencoded()to populatereq.body extended: truelets URL-encoded forms carry nested objects and arrays
📚 Additional Resources
🚀 What's Next?
You've handled text-based input from the URL and the body. But what about binary data — images, PDFs, videos? Those come in as multipart/form-data, which the built-in parsers don't touch. Next: File uploads with Multer, the middleware built for exactly this job.
🎉 Well done!
Search, filter, sort, paginate — the query-string toolkit behind every real list endpoint is now yours.