URL Parameters
Path params identify, query params refine — and both are untrusted strings that have to be parsed, validated and canonicalised before anything renders.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What belongs in the path, what belongs in the query string, and who is responsible for the fact that all of it is a string typed by a stranger?
Someone filters a list to open invoices, sorts by amount, goes to page three, and sends the link to a colleague. They expect the colleague to see the same three things they see.
Read params.id and searchParams.get('page') where you need them, coerce with Number() at the point of use, and write new values back with pushState whenever a control changes. The URL is a bag of strings; treat it like one.
Number(searchParams.get('page')) is 0 when the parameter is absent and NaN when it is "abc", and both flow into a request as ?page=NaN before anyone notices (Validation Errors: Feedback, Not Verdicts in API Design).
Number(searchParams.get('page'))is0when the parameter is absent andNaNwhen it is"abc", and both flow into a request as?page=NaNbefore anyone notices (Validation Errors: Feedback, Not Verdicts in API Design).- Typing in a search box pushes a history entry per keystroke. Back now requires eleven presses to leave the page, and the user concludes the back button is broken (History and Navigation).
- The same view is reachable at
?sort=date&page=1,?page=1&sort=dateand?sort=date— three URLs, three cache keys, one screen, and a cache that never hits (Query Keys and Invalidation). - A filter value containing
&,#or a space is written into the URL unencoded, truncating everything after it and silently dropping the rest of the state. - A support engineer pastes a customer URL into a ticket and the customer's email address goes with it, because it was a query parameter — and now it is in the ticket system, the referrer header and the analytics pipeline (Session Replay and the Privacy It Costs).
- A crafted
?next=https://example.evil/loginsends a user off-origin after sign-in, from a link that looks entirely like yours (Login Redirects and the Open-Redirect Trap).
What is actually happening
In the browser, not in the framework.
- Path parameters are positional segments bound to names by the route pattern. They answer "which thing" — an identity. Changing one is a different resource and therefore a different page.
- Query parameters are an unordered set of key-value pairs after
?. They answer "which view of the thing" — filter, sort, page, tab, selection. Changing one is usually the same page, refined. - The fragment after
#never reaches the server. The browser uses it to scroll to an element with a matching id or name; a router that hijacks it takes that behaviour over (Document Structure and Reading Order). - Everything in all three is percent-encoded text. There are no numbers, booleans, dates, arrays or objects in a URL — only strings and whatever convention you invented to encode them.
URLSearchParamsparses and serialises the query correctly, including repeated keys (?tag=a&tag=byieldsgetAll('tag') === ['a', 'b']). It also applies form-encoding rules, which is why a space becomes+on the way out and back on the way in.- There is no standard for nested structures.
?filter[status]=open,?filter=status:openand a base64 blob are all conventions; whichever you pick, the client and the server have to agree, and so does anything that generates links (Filtering: An Allowlist With an Index Bill in API Design). - A canonical URL is the one form you consider authoritative: defaults omitted, keys in a stable order, values normalised. It matters because it is simultaneously a cache key, an analytics key, a share target and a browser history entry.
What this makes the browser do
And which of it is avoidable.
- Parsing a URL is trivial work; the browser has a purpose-built parser and
new URL()exposes it. This is not a place to spend optimisation effort. - What is not trivial: every URL change that your code treats as a state change triggers a match, a re-render and often a request. A parameter written on every keystroke is a render and a fetch on every keystroke (Five Components, One Request).
- Each
pushStateadds a session history entry, which the browser retains for the tab along with its scroll position and state object. Thousands of entries is real memory and a genuinely unusable back button. - Avoidable work: re-parsing the query on every render rather than deriving it once per location change, and producing a new object identity each time, which then invalidates every memoised consumer downstream (Derived State).
Path or query
This decision is usually made per-parameter, in a hurry, by whoever adds the feature — which is how one application ends up with /orders/open, /orders?status=open and /orders/filter/open all in production. The criteria below are worth writing down once, because the cost of inconsistency is paid by every person who tries to build a link.
The useful reframing: the path is a hierarchy of things, the query is a set of adjustments to how you are looking at one. If removing the parameter still leaves a sensible page, it is a query parameter. If removing it leaves nothing to render, it is part of the path.
- A rule of thumb that resolves most arguments: if two users looking at the same URL should see different things, the difference does not belong in the URL.
- If a value is long, structured or private, put an identifier in the URL and keep the value on the server (Who Owns This State?).
A value must survive a reload and a share. Path segment, query parameter, fragment, or not in the URL at all?
when It identifies the resource or a level of hierarchy: /orders/7, /teams/acme/members. Removing it makes the URL meaningless.
cost It is structural. Changing the shape breaks every saved link and every server rewrite rule, and every value needs a not-found answer (Route Matching).
when It refines a view that is valid without it: filter, sort, page, tab, expanded row, selected item.
cost Unordered and open-ended, so canonicalisation is on you, and unknown keys must be tolerated or old links break.
when It addresses a position within the page — a heading anchor, a documentation section. The browser scrolls to it for free.
cost Never sent to the server, so it cannot be used for server rendering or logged for support, and hijacking it costs you native anchor behaviour.
when It is transient, private, or per-device: an unsent draft, a hover state, an access token, a dismissed banner.
cost It does not survive a reload and cannot be shared, so anything a user might reasonably want to send to a colleague is now unsendable (The Seven Kinds of State).
Every parameter is a string from a stranger
The address bar is an editable text field on a page you do not control. Anything read out of it arrives as a string, may be absent, may be repeated, may be malformed, and may be deliberately hostile. The remedy is not scattered defensive coercion — it is one parse at the route boundary that produces a value the rest of the code can trust.
Note what the parse below returns. Not "the params, coerced" but a typed object with defaults applied and a canonical URL to redirect to when the input was not already canonical. Producing the canonical form here is what makes the cache key, the analytics path and the shared link agree, and it is one line at the only place that has all the information.
1const SORTS = ['date', 'amount', 'status'] as const2type Sort = (typeof SORTS)[number]3 4interface OrderListQuery {5 page: number6 sort: Sort7 status: string[]8 q: string9}10 11const DEFAULTS: OrderListQuery = { page: 1, sort: 'date', status: [], q: '' }12 13function parseOrderListQuery(url: URL): OrderListQuery {14 const sp = url.searchParams15 16 const rawPage = Number(sp.get('page'))17 const page = Number.isInteger(rawPage) && rawPage >= 1 ? rawPage : DEFAULTS.page18 19 const rawSort = sp.get('sort')20 const sort = SORTS.includes(rawSort as Sort) ? (rawSort as Sort) : DEFAULTS.sort21 22 return {23 page,24 sort,25 status: sp.getAll('status').filter((s) => s === 'open' || s === 'paid'),26 q: (sp.get('q') ?? '').slice(0, 200), // bound it: URLs have limits27 }28}29 30/** The inverse. Defaults are omitted, so one view has exactly one URL. */31function serializeOrderListQuery(q: OrderListQuery, base: URL): URL {32 const url = new URL(base.pathname, base.origin)33 if (q.page !== DEFAULTS.page) url.searchParams.set('page', String(q.page))34 if (q.sort !== DEFAULTS.sort) url.searchParams.set('sort', q.sort)35 for (const s of [...q.status].sort()) url.searchParams.append('status', s)36 if (q.q) url.searchParams.set('q', q.q)37 return url // URLSearchParams handles encoding38}Three things are load-bearing and easy to skip. Number.isInteger rather than a truthiness check, because Number('') is 0 and Number('x') is NaN and neither throws. getAll rather than get, because a repeated key is legal and dropping the extras loses state silently. And the sorted, default-omitting serialiser, which is what makes serialize(parse(url)) stable — the property worth actually testing.
Writing back without flooding history
Reading parameters is the easy direction. Writing them is where the back button gets destroyed, because the natural implementation — push a new URL whenever a control changes — is correct for a page number and catastrophic for a search box.
The rule is about user intent rather than about frequency. A user expects back to undo a decision, not to undo a character. Anything continuous should replace the current entry; anything discrete should push a new one. When in doubt, ask what the user would expect one press of back to do, and implement that.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
A search box writes ?q= on every keystroke with pushState | Back takes eleven presses to leave the page | One history entry per character; the browser is faithfully doing what it was told | Debounce, then replaceState while typing. Push once, on submit or on blur, if at all (History and Navigation). |
?page=abc from an edited address bar | A request goes out with page=NaN, or the list renders empty with no message | Number() coercion with no validation and no fallback | Validate at the boundary, clamp to a legal value, and redirect to the canonical URL with a replace. |
| Parameters serialised in insertion order | Cache hit rate near zero for a list that has not changed | ?sort=date&page=2 and ?page=2&sort=date are different keys for one view | Serialise in a fixed order and omit defaults, so the view has exactly one URL (Query Keys and Invalidation). |
A filter value containing & or #, concatenated into a URL string | Everything after the character disappears from the state | Manual string building instead of URLSearchParams, so reserved characters are not encoded | Never concatenate URLs. Build with URL and URLSearchParams and let them encode. |
Canonicalising with pushState on arrival | Back appears broken; one press returns the user to the same screen | The correction pushed an entry, so back goes to the pre-correction URL, which corrects forward again | Canonical redirects always replace. Any navigation the user did not perform should not be in their history (History and Navigation). |
How to build it
Most important first.
- Decide path versus query by asking whether the value identifies the resource or describes the view of it. Identity goes in the path; everything else goes in the query.
- Parse and validate the whole parameter set once, at the route boundary, into a typed object with defaults applied. Every consumer downstream then reads real values instead of strings (Parse, Do Not Validate in Backend).
- Decide what an invalid value does, explicitly: clamp it, fall back to the default, or redirect to the canonical URL. Crashing and rendering an empty page are the two answers you get by not deciding.
- Use
replaceStatefor continuous changes — typing, dragging a slider, resizing a pane — andpushStatefor discrete ones the user would expect back to undo: opening a record, changing a tab, going to the next page (History and Navigation). - Omit defaults when serialising. If
page=1andsort=dateare the defaults, the canonical URL for the default view is the bare path, and every link to it agrees. - Keep secrets, tokens and personal data out of the URL entirely. URLs are logged by proxies, CDNs, analytics and error trackers, they appear in referrer headers, and users paste them into places you will never see (What You Just Wrote Into a Log Half the Company Can Read in Performance).
- Round-trip test the encoding: take a value containing a space, an ampersand, a hash, a slash and a non-Latin character, write it, read it back, and assert equality.
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- Changing a filter changes the content of the page without a navigation announcement. Screen-reader users need the result of the change spoken — "24 results" — through a polite live region attached to the results container (Live Regions and Announcement).
- Do not move focus when a query parameter changes as a refinement. The user is usually still in the control they are operating; yanking focus to the results makes the filter unusable with a keyboard (Focus Management).
- A parameter-driven state that has a visible control must keep that control in sync. A URL that says
sort=amountwhile the select still shows "Date" is a mismatch that a screen-reader user has no way to detect (State Synchronization). - Shareable URLs are an accessibility feature in their own right: they let someone hand off a precise view to a person using different assistive technology, instead of describing a sequence of clicks.
- Keep the fragment working. Users of screen readers and keyboards rely on in-page anchors and skip links, and a router that swallows
#breaks both (Keyboard Operability).
What can go wrong
- Silent coercion.
Number(undefined),Number('')andNumber('12abc')produce three different wrong answers, none of which throws. - History flooding from high-frequency parameter writes, which makes back unusable and is nearly always discovered by a user rather than a test.
- Encoding applied twice, so
%20becomes%2520and the value gains literal percent signs each time the URL is rebuilt. - Parameters that describe state the UI no longer has: a saved link with
?tab=billingafter the billing tab was removed. If unknown parameters are not tolerated, an old bookmark becomes a crash. - The mitigation fails too: canonicalising on every navigation with
pushStateinstead ofreplaceStatecreates a history entry per correction, so back returns the user to the non-canonical URL, which redirects forward again — a loop the user cannot escape except by holding back. - URL length limits. Serialising a large multi-select into the query works until a proxy, CDN or server rejects the request line, and the limit is not the same in every hop (Payload Size: 20KB, 200KB, 5MB in API Design).
- A debounced URL write and a manual navigation: the pending write can land after the user has navigated away, restoring a filter onto the wrong page.
- Two controls writing the query at once — a filter and a sort applied in quick succession — where each reads the URL it saw rather than the current one, and the second write drops the first (The Lost Update, Step by Step in API Design).
- Responses for successive parameter values arriving out of order, so the results for
?q=carender after the results for?q=cat(Out-of-Order Responses).
- Every parameter is attacker-controlled. Treat a rendered parameter exactly as you would treat a rendered comment: escape it, and never hand it to an HTML sink (Cross-Site Scripting).
- A parameter used to build a request path or a storage key needs allowlisting, not sanitising.
.., encoded separators and absolute URLs all arrive here (Path Traversal). - Redirect parameters must be validated against a list of allowed paths on your own origin. Checking that the value "starts with the site name" is defeated by
https://yoursite.evil.example(Login Redirects and the Open-Redirect Trap). - A URL is not a confidentiality boundary. It appears in browser history, in the
Refererheader sent to third-party resources, in server and CDN access logs, and in every screenshot (Cookies vs Script-Readable Tokens). - Client-side validation of a parameter is a UX affordance. The server validates the same value again, because the client's copy of the rule is advisory (What the Frontend Is Responsible For in Auth).
- "Path parameters are for ids and query parameters are for everything else." Closer: the path is identity and hierarchy, the query is refinement. A filter that changes *which* resource you are looking at usually belongs in the path.
- "
encodeURIComponenteverywhere is safe." It is correct for a single component and wrong for a whole URL — encoding an entire URL encodes its separators too. Build withURLandURLSearchParamsinstead of concatenating. - "The router validates parameters." Routers extract them. Validation is yours, and the difference surfaces the first time somebody edits an id in the address bar.
- "Query parameters are hidden from the server." They are sent with every request and logged by everything in between. Only the fragment stays in the browser.
- "Storing state in the URL is the same as storing it in
localStorage." One is per-link and shareable, the other is per-browser and private. They answer different questions and the choice is a design decision (Choosing Browser Storage).
Measuring it, and what changes in the field
- Watch the address bar while operating the UI. History flooding, missing canonicalisation and unencoded values are all visible without any tooling.
- The Network panel shows the consequence: a request per keystroke, or a request with
page=NaN, or two requests for URLs that describe the same view (Debugging the Network). - Cache instrumentation is the sharpest signal for canonicalisation problems — a low hit rate on a query cache usually means the key varies where the view does not (Query Keys and Invalidation).
- Analytics path cardinality is the tell for parameters that should have been path segments or should not have been in the URL at all (Cardinality: The Label That Took Down Monitoring in Performance).
- On a slow network, each parameter change that triggers a fetch is a visible wait, so continuous controls need debouncing and cancellation rather than a request per change (Cancelling a Request Nobody Is Waiting For).
- With a large result set, page and cursor parameters become correctness-critical: an offset into a shifting list gives a different page tomorrow, which is why cursors exist (Cursor Pagination: An Opaque Bookmark, Not a Position in API Design).
- In a long-lived tab, an old URL shape can outlive the code that produced it. Tolerating unknown parameters is what keeps a six-month-old bookmark working (Long-Lived Clients and Version Skew).
- Across locales, parameter *values* are localised text and must survive encoding; parameter *names* should not be localised, or every link becomes locale-specific (Internationalization).
- Putting view state in the URL makes it shareable and costs you a migration problem: the URL is now a public contract, and changing a parameter name breaks every saved link.
- Canonicalisation improves caching and analytics and adds a redirect on some inbound links — an extra round trip on exactly the visits that came from elsewhere.
- A validated, typed parameter layer is more code than reading
searchParamswhere you need it, and it is the code that turns a class of silent wrongness into a single explicit decision. - Rich state in the query keeps the server stateless and makes URLs long and ugly. Server-stored views produce short URLs and require a record, an owner and a lifetime (Who Owns This State?).
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALPercent-encoding, the path/query/fragment split, repeated query keys and the fragment never being sent to the server are URL and HTTP semantics, identical across browsers and servers.
- FRAMEWORK-SPECIFICParameter ergonomics vary: some routers hand you a typed, validated object per route and re-run a loader when it changes, others hand you a raw string map and leave parsing, defaults and history mode entirely to you. Whether a parameter change remounts the view or updates it in place is also a router decision, not a browser one.
- SPEC-EVOLVINGReferrer behaviour, cross-site query-parameter stripping and link-decoration countermeasures are actively changing in browsers, so what leaves your origin in a URL today is not a stable assumption. Treat referrer policy as something to set explicitly rather than inherit.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Software Design — parse, do not validate, as a general principle: a parsing function that returns a narrower type is the cheapest way to stop a string leaking through five layers before anyone checks it.