Learn how to read query strings with req.query in Express 5, handle missing, repeated and encoded values safely, and choose between the simple and extended query parsers.

Bishal Biswas
Self Learner
What you will learn
- What a query string is and when to use it instead of a route parameter
- How to read values with
req.query, and why every value is a string (or an array) - How to handle missing, empty, repeated and encoded values safely
- Why
req.queryis read-only in Express 5 - The difference between the simple and extended query parsers, and how to switch
What is a query string?
Look at a search URL from almost any shop:
https://shop.example.com/search?q=running+shoes&size=42&sort=price
\_________________________________/
query string
The query string is everything after the ?. It is a list of key/value pairs written as key=value and separated by &. Here there are three pairs: q is running shoes, size is 42, and sort is price.
Query strings carry extra options for a request: what to search for, which page to show, how to sort or filter. They are not part of the path, so they don’t affect which route matches. /search and /search?q=shoes both match app.get('/search', ...).
Route parameter or query string?
Both put data in the URL, so which should you use? A good rule of thumb:
| Use a route parameter when… | Use a query string when… |
|---|---|
| The value identifies a thing | The value adjusts how you see things |
| It is required | It is usually optional |
/products/42 (product 42) |
/products?color=red&page=2 (filter and page the list) |
Think of a library. The shelf number tells you which book (route parameter). Asking for “large print, newest edition first” tells the librarian how you want it (query string).
Reading values with req.query
Express parses the query string for you and puts the result in req.query, a plain object:
// server.js
import express from 'express';
const app = express();
app.get('/search', (req, res) => {
res.json(req.query);
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
curl "http://localhost:3000/search?q=shoes&size=42&sort=price"
{"q":"shoes","size":"42","sort":"price"}
Warning: Always put URLs with a query string in quotes in the terminal. The
&character has a special meaning to shells: in Bash it runs the command in the background, and in PowerShell it is an error. Without quotes, everything after the first&gets lost. (On Windows PowerShell, also remember to typecurl.exe.)
Notice that size came back as "42" with quotes: every value in req.query is a string, just like route parameters.
The values you can get
Query strings come from users, browsers and other programs. They can be messy, so it pays to know every shape a value can take:
| URL | req.query |
|---|---|
/search?q=shoes |
{ q: 'shoes' } |
/search |
{}, so req.query.q is undefined |
/search?q= |
{ q: '' } (an empty string) |
/search?q |
{ q: '' } (also an empty string) |
/search?q=a&q=b |
{ q: ['a', 'b'] } (an array!) |
/search?q=running+shoes |
{ q: 'running shoes' } (+ means space) |
/search?q=c%2B%2B |
{ q: 'c++' } (%2B is an encoded +) |
The repeated key is the surprise. If a key appears twice, you get an array instead of a string. That can crash code that expects a string:
app.get('/greet', (req, res) => {
// If someone visits /greet?name=a&name=b, name is an array
// and .toUpperCase() throws a TypeError -> 500 error
res.send(`HELLO ${req.query.name.toUpperCase()}`);
});
URL encoding in one minute
Some characters can’t appear in a URL as they are, because they already mean something (&, =, ?, #) or aren’t allowed (spaces). URL encoding (also called percent-encoding) replaces them with % followed by a code. Express decodes them for you, so req.query always holds the real text.
| Character | Encoded |
|---|---|
| space | %20 or + |
& |
%26 |
= |
%3D |
+ |
%2B |
# |
%23 |
When you build URLs in JavaScript, let URLSearchParams do the encoding:
const params = new URLSearchParams({ q: 'rock & roll', page: 2 });
console.log(`/search?${params}`); // /search?q=rock+%26+roll&page=2
Reading query values safely
Because values can be missing, empty, the wrong type or arrays, real code should clean them up before using them. Here is a small, realistic example: a product list with paging and sorting.
// server.js
import express from 'express';
const app = express();
const products = Array.from({ length: 25 }, (_, i) => ({
id: i + 1,
name: `Product ${i + 1}`,
price: (i * 7) % 50 + 5,
}));
// Take the first value if a key was repeated
const first = (value) => (Array.isArray(value) ? value[0] : value);
app.get('/products', (req, res) => {
// 1. Read, with defaults for missing values
const page = Number(first(req.query.page) ?? 1);
const limit = Number(first(req.query.limit) ?? 10);
const sort = first(req.query.sort) ?? 'id';
// 2. Check the values make sense
if (!Number.isInteger(page) || page < 1) {
return res.status(400).json({ error: 'page must be a whole number of 1 or more' });
}
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
return res.status(400).json({ error: 'limit must be between 1 and 50' });
}
if (!['id', 'price'].includes(sort)) {
return res.status(400).json({ error: 'sort must be "id" or "price"' });
}
// 3. Use them
const sorted = [...products].sort((a, b) => a[sort] - b[sort]);
const start = (page - 1) * limit;
res.json({
page,
limit,
sort,
total: products.length,
items: sorted.slice(start, start + limit),
});
});
app.listen(3000, (error) => {
if (error) throw error;
console.log('Listening on http://localhost:3000');
});
Try a few requests:
curl "http://localhost:3000/products"
curl "http://localhost:3000/products?page=3&limit=10"
curl "http://localhost:3000/products?sort=price&limit=3"
curl "http://localhost:3000/products?page=zero"
The last one returns 400 with {"error":"page must be a whole number of 1 or more"}, because Number('zero') is NaN (Not a Number), which is not an integer.
The three steps are worth remembering: read with defaults, check, then use. Writing these checks by hand gets long; in Request Validation with Zod you will describe the rules in one place instead. Real-world paging and filtering patterns are covered in Pagination, Filtering and Sorting.
Tip:
??only replacesundefinedandnull. For?page=(an empty string),first(req.query.page) ?? 1gives'', andNumber('')is0, which the check then rejects. That’s the behaviour you want: an empty value is a mistake, so the client gets a clear400.
req.query is read-only in Express 5
In Express 4, some code changed req.query directly, for example to replace it with cleaned-up values. In Express 5, req.query is a getter: a property that is calculated each time you read it, and that you cannot assign to.
app.get('/products', (req, res) => {
req.query = { page: 1 }; // TypeError: Cannot set property query of #<IncomingMessage> which has only a getter
});
Because ES modules always run in strict mode, that assignment throws an error. Changing a single key (req.query.page = 1) won’t throw, but it won’t reliably stick either. The safe habit is simple: treat req.query as read-only, and copy the values you need into your own variables, as the products example does with page, limit and sort.
// Good: build your own object
const filters = {
page: Number(first(req.query.page) ?? 1),
search: first(req.query.q) ?? '',
};
The query parser: simple vs extended
Turning the text q=shoes&size=42 into an object is the job of the query parser. Express 5 offers two built-in parsers, and the default changed from Express 4.
| Setting | Parser | Default in |
|---|---|---|
'simple' |
Node’s built-in querystring module: flat keys only |
Express 5 |
'extended' |
The qs package: understands brackets for nested objects |
Express 4 |
The difference shows up with square brackets in keys:
/products?filter[color]=red&filter[size]=m&tags[]=new&tags[]=sale
With the default simple parser, the brackets are just part of the key names:
{
"filter[color]": "red",
"filter[size]": "m",
"tags[]": ["new", "sale"]
}
With the extended parser, brackets create nested objects and arrays:
{
"filter": { "color": "red", "size": "m" },
"tags": ["new", "sale"]
}
Switching parsers
Use app.set('query parser', ...) near the top of your app, before your routes:
// server.js
import express from 'express';
const app = express();
app.set('query parser', 'extended'); // nested objects from brackets
app.get('/products', (req, res) => {
res.json(req.query);
});
To test bracket URLs with curl, add the -g flag. Without it, curl treats [ and ] as its own special pattern characters and fails with a “bad range” error:
curl -g "http://localhost:3000/products?filter[color]=red&filter[size]=m"
{"filter":{"color":"red","size":"m"}}
Which should you choose?
Stick with the default 'simple' parser unless you have a specific need for nested query objects. Flat keys like ?color=red&size=m are easier to read, easier to validate, and avoid surprises.
The extended parser is useful when a front-end library or an existing API already sends bracket-style queries. If you turn it on, be extra careful: a client can send any shape, so req.query.filter might be a string, an array or an object. Always validate before use.
Warning: Never pass
req.query(or any part of it) straight into a database query. With nested objects, a client could send something like?price[$gt]=0and change what your query means. You’ll learn how to defend against this in Preventing Injection, XSS and CSRF.
Other options
The setting also accepts false and a function:
// Turn query parsing off: req.query is always {}
app.set('query parser', false);
// Bring your own: here, keep only the last value of each key
app.set('query parser', (queryString) => Object.fromEntries(new URLSearchParams(queryString)));
With the custom function above, /search?q=a&q=b gives { q: 'b' } because Object.fromEntries keeps the last value. You rarely need either option, but now you know they exist.
Common mistakes
Common mistake: Forgetting quotes around URLs in the terminal.
curl http://localhost:3000/search?q=a&page=2losespage=2(or errors in PowerShell). Writecurl "http://localhost:3000/search?q=a&page=2".
Common mistake: Doing maths on query values without converting them.
req.query.page + 1with?page=2gives'21'(string joining), not3. UseNumber(...)first.
Common mistake: Expecting
?filter[color]=redto givereq.query.filter.color. In Express 5 the default simple parser gives the key'filter[color]'. Useapp.set('query parser', 'extended')if you need nesting.
Key takeaways
- The query string is the part after
?:key=valuepairs joined by&. It doesn’t affect which route matches. - Use route parameters to identify a resource and query strings to filter, sort or page it.
req.queryvalues are strings, an empty string,undefinedwhen missing, or an array when a key repeats. Read with defaults, check, then use.- In Express 5,
req.queryis a read-only getter. Copy values into your own variables instead of changing it. - The default parser is
'simple'(flat keys).app.set('query parser', 'extended')turns brackets into nested objects.
Practice
- Write
GET /greetthat reads?name=and respondsHello, <name>!, orHello, stranger!when no name is given. Make sure?name=a&name=bdoesn’t crash it. - Add a
?minPrice=filter to the products example that only returns products at or above that price. Reject values that aren’t numbers with a400. - Visit
/products?filter[color]=redwith the simple parser and then with the extended parser. Compare the tworeq.queryobjects. - Use
URLSearchParamsin the Node.js REPL to build a query string for{ q: 'c++ & rust', page: 1 }, then paste the URL into curl and check whatreq.queryshows.
What’s next
You now know how Express reads paths and query strings. Next, find out how Express picks a route when several could match. Continue with Route Order and Path Matching.
Was this explanation helpful?
Your feedback helps us improve the content for everyone.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready