What is a query string?
A query string is the part of an address after a question mark. In /menu?type=pastry&max=4, the path is /menu and the query string is type=pastry&max=4. It is a list of key and value pairs: each pair is written key=value, and an ampersand & separates one pair from the next.
Query strings carry options. The page is still the menu, and the query says how the visitor wants to see it: only pastries, only cheap items, sorted by price, page two of the results. Search boxes, filters and sort menus on almost every website put their choices in the query string, which is why you see long addresses after you search for something.
The browser sends the query string with the path, on the first line of the request. Press **GET /menu?type=pastry** and open the request in the Requests view: the full address is there, and the route that handled it is still app.get('/menu'). Express matches routes on the path alone and ignores the query while it decides which route runs.
How does Express read the query string?
Express parses the query string before your route runs and puts the result in req.query, a plain object. For /menu?type=pastry&max=4, req.query is { type: 'pastry', max: '4' }. When there is no query string, req.query is an empty object {}, so reading req.query.type gives undefined instead of throwing an error.
The starting code prints req.query on every request. Press each Try button and watch the Log. The plain GET /menu prints Query: {}, and the others print the keys you sent. Reading the Log like this is the fastest way to find out what a request really contained, which helps when the address came from a form or a link someone else wrote.
Express also decodes the values for you. A space in an address is sent as %20 or +, so ?q=cinnamon%20bun arrives in req.query.q as 'cinnamon bun'. In Express 5 req.query is read-only: you read from it and copy values into your own variables, and you never assign to it.
// GET /menu?type=pastry&max=4
app.get('/menu', (req, res) => {
console.log(req.query); // { type: 'pastry', max: '4' }
console.log(req.query.type); // 'pastry'
console.log(req.query.sort); // undefined: nobody sent it
});Why is every value a string?
An address is text, so every value in req.query is text too. ?max=4 gives the string '4', and ?fresh=true gives the string 'true'. Comparing a price with a string sometimes appears to work, because JavaScript converts it without telling you, and sometimes gives wrong answers. Turn values into the type you need before you use them.
For numbers, use Number(req.query.max). Check the result with Number.isNaN(), because Number('abc') and Number('four') both give NaN, and every comparison with NaN is false. If you filter with item.price <= NaN, every item fails the test and the visitor sees an empty menu with no explanation. Ignoring a broken filter, or answering 400 Bad Request, are both reasonable. A silent empty page is the worst of the three.
For yes or no options, compare the text: const fresh = req.query.fresh === 'true';. Writing Boolean(req.query.fresh) looks tidy but gives true for the string 'false', because any non-empty string counts as true.
How do optional filters with defaults work?
Every key in a query string is optional. A visitor can type the address by hand, a link can leave a key out, and a search engine can visit the plain /menu. Write each filter as its own small step that only runs when its key is present, and start from the full list.
The starting route does exactly this for type. It begins with every item, and the if (req.query.type) block narrows the list only when a type was sent. A max filter follows the same pattern underneath. Because each step works on the result of the one before, the filters combine for free: /menu?type=pastry&max=2.5 keeps pastries, then keeps the ones at 2.50 or less.
Some options need a value even when nobody sends one, such as how many results to show or which order to sort in. Give those a default. The || operator works when the default should replace anything empty or invalid, since NaN, 0 and '' all count as false. Use ?? when only a missing value should be replaced and 0 is a meaningful answer.
let items = menu;
if (req.query.type) {
items = items.filter((item) => item.type === req.query.type);
}
const max = Number(req.query.max);
if (req.query.max && !Number.isNaN(max)) {
items = items.filter((item) => item.price <= max);
}
const limit = Number(req.query.limit) || 20; // default: 20 items
items = items.slice(0, limit);

What happens when a key appears twice?
A query string can repeat a key: /menu?type=bread&type=pastry. Checkbox groups in HTML forms do this whenever someone ticks two boxes with the same name. Express 5 turns a repeated key into an array, so req.query.type becomes ['bread', 'pastry'], while a single ?type=bread stays a plain string.
Code that assumes a string breaks on the array. req.query.type.toLowerCase() throws a TypeError because arrays have no toLowerCase(), and the visitor gets a 500 page. Anyone can type any address, so a route should never crash because of an unusual query string.
When you want to accept several values, normalise them: [].concat(req.query.type || []) always gives an array, empty when the key is missing. When you only want one value, check typeof value === 'string' and ignore anything else. Both choices are fine. Pick one on purpose.
// ?type=bread -> ['bread']
// ?type=bread&type=pastry -> ['bread', 'pastry']
// no type at all -> []
const types = [].concat(req.query.type || []);
if (types.length) {
items = items.filter((item) => types.includes(item.type));
}When should you use a route parameter instead?
Route parameters and query strings both carry values in the address, and they answer different questions. A parameter names the thing: /menu/3 is the croissant, and /members/ada is one bird club member. Without the parameter the address points somewhere else entirely. A query string adjusts how you see a thing: /menu?sort=price is still the menu, in a different order.
A handy test: if leaving the value out still makes a sensible page, it belongs in the query string. /menu without ?max=4 is the whole menu, which makes sense. /menu/ without the 3 is a different page. Required values go in the path, optional ones go in the query.
The two mix well. A bike shop could use /bikes/42/repairs?status=open for the open repairs on bike 42: the path picks the bike, the query narrows its repairs. In Express the route is app.get('/bikes/:id/repairs', ...), and the handler reads req.params.id and req.query.status. Mixing them up is a common bug: req.params.status would be undefined here, because status is not a named part of the path.
How do you check filters in the Requests view?
Send each combination and compare the answers. The Requests view shows the full address you sent, the route that handled it and the size of the response. A filtered menu should be smaller than the full one, and if two different queries give responses of exactly the same size, the filter probably did nothing.
Try the awkward cases on purpose: no query at all, an empty value like ?max=, text where a number belongs like ?max=lots, a repeated key, and capital letters like ?type=Pastry. Each one should give a sensible page. The Log shows req.query for each request, so you can see what the route received before it decided anything.
What should you watch for in AI-written filter code?
AI tools often compare query values without converting them, as in item.price <= req.query.max. It passes a quick test because JavaScript converts the string, then fails on other input. Look for Number() and a NaN check near every numeric filter.
Watch for two more patterns. Some generated code passes the whole of req.query straight into a database search or an object merge, which lets a visitor add keys you never planned for. Pick out the keys you expect by name instead. And some code reads req.params for a value that comes from the query string, or the other way round, which gives undefined with no error at all.
Field notes
- A query string is the part of an address after
?, made of key and value pairs. - Express puts those pairs in
req.querybefore your route runs. - Every query value is text, so turn numbers into numbers before comparing them.
- Every key is optional, so the route must still work when the filter is missing.
Easy to get wrong
req.query.max is text. Comparing it to a number relies on silent conversion, and text that is not a number turns into NaN, which empties the list. Convert once, check for NaN, then filter.
req.params only holds named parts of the route path, like :id. Values after the ? are always in req.query.
Routes match the path only. A route with a query string in it never matches anything, so every request falls through to the 404 page.
Any non-empty string is true in JavaScript, including 'false'. Compare the text to the exact value you expect.
Questions people ask
Does the order of keys in a query string matter?
No. ?type=pastry&max=4 and ?max=4&type=pastry give the same req.query object. Write your filters so the order they run in does not change the result.
Is a query string private?
No. It is part of the address, so it appears in browser history, in server logs and in links people share. Never put passwords, tokens or personal details in a query string. Forms that send private data use POST and a request body, which a later lesson covers.
What about the part after a # sign?
The fragment, like #prices, never reaches the server. The browser keeps it and uses it to scroll the page. Your route cannot read it from req.query or anywhere else.
How do I make a link with a query string safely?
Build it with URLSearchParams, which escapes spaces and symbols: '/menu?' + new URLSearchParams({ type: 'pastry', q: 'cinnamon bun' }). Gluing strings together by hand breaks as soon as a value contains & or =.