Route params, query strings and bodies
The three places a request carries data in Express: :id route params, ?limit= query strings, and JSON bodies, with real Linkstash routes for each.

A URL can carry data three different ways, and mixing them up is one of the fastest ways to write a route that looks right and behaves wrong. Here's how Linkstash uses all three, and when each one is the correct choice.
Route params: identifying one resource
When a URL points at a specific thing, the identifier goes in the path itself. Linkstash's single-link routes look like this:
// Not-yours and not-found deliberately return the same 404. See API.md.
app.get("/links/:id", requireAuth, (req, res) => {
const link = getLink(db, Number(req.params.id));
if (!link || link.userId !== req.userId) {
res.status(404).json({ error: "NotFound" });
return;
}
res.json(link);
});:id in the route path is a placeholder. When a request comes in for /links/42, Express matches the pattern and puts "42" on req.params.id. Note the type: it's a string, always, straight off the URL, so Number(req.params.id) is doing real work there, not a formality. Skip it and you'd be comparing a string id against an integer primary key and nothing would ever match.
That comment above the route is worth pausing on. getLink fetches a link by its raw id, with no owner check baked into the query. The owner check happens right after, in JavaScript: !link || link.userId !== req.userId. Both "this link doesn't exist" and "this link exists but isn't yours" produce the exact same 404 NotFound. That's not a shortcut, it's a deliberate decision covered in the next lesson on REST design: a 403 Forbidden would confirm the link exists, which is itself information you shouldn't hand to someone who doesn't own it.
Route params are for identifying a specific resource: /links/:id, /users/:userId, /orders/:orderId/items/:itemId. If a value narrows down which one thing you mean, it's a param.
Query strings: options on a collection
GET /links doesn't point at one link, it points at a collection, so options for that collection go after the ?, not in the path:
app.get("/links", requireAuth, (req, res) => {
const limit = intParam(req.query.limit, 20, 1, 100);
const offset = intParam(req.query.offset, 0, 0, Number.MAX_SAFE_INTEGER);
const q = typeof req.query.q === "string" ? req.query.q : undefined;
res.json({ data: listLinks(db, req.userId!, { limit, offset, q }), limit, offset });
});req.query is an object Express builds from everything after the ?. Request /links?limit=10&q=logic and req.query is { limit: "10", q: "logic" }, both strings again, same as params. intParam (you'll meet it properly in lesson 12) converts and clamps limit and offset into safe ranges. q gets a plain typeof check because query values can technically arrive as arrays (?q=a&q=b) or be missing entirely, and the API only wants a single optional string.
The rule of thumb: if it narrows or shapes a list without identifying one specific item, it belongs in the query string. Pagination, filtering, sorting, search terms, all query strings, never params.
Request bodies: data the client is sending you
Params and query strings both come from the URL, which has practical size limits and shows up in server logs and browser history. A body is different: it's a separate chunk of data sent along with the request, meant for POST, PUT, and PATCH, where the client is handing over something to create or change.
app.post("/links", requireAuth, (req, res) => {
const parsed = newLink.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "ValidationError", fields: parsed.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })) });
return;
}
res.status(201).json(createLink(db, req.userId!, parsed.data));
});req.body only exists because of express.json() from the last lesson. Without it, this would be undefined and .safeParse(undefined) would fail validation on every request, not because the client did anything wrong, but because nothing ever parsed the bytes into an object.
Watch it live
The panel below replays real Linkstash requests against the exact status logic you just read: params picking one link, a missing auth header, a valid and an invalid body.
Click through the tabs. The first request with no Authorization header gets a 401 before Express even looks at what it's asking for, because requireAuth runs before the route handler does. /links/999 returns 404, because no link with that id belongs to anyone the token resolves to. The second POST, missing a title, gets 400 with a field-level error instead of a generic failure message.
Same name, different place, different meaning
id shows up as a route param on /links/:id and as a JSON field on the Link object Linkstash returns. Those are two different things that happen to share a name: one identifies which resource you're addressing, the other is data about that resource. Keep the mental model separate and query strings, params, and bodies stop feeling arbitrary.
Quick check
A client wants to fetch links whose title contains 'react', 20 per page, starting from the 40th result. Where do limit, offset, and the search term belong?
For the underlying protocol these all ride on top of, headers, status lines, the request/response cycle itself, HTTP: the request and response contract covers it directly. Next in this series: middleware, the chain every request walks, which is what requireAuth actually is.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


