RoutingGENERALFRAMEWORK-SPECIFICSPEC-EVOLVING

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.

The question

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?

The user intent

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.

The obvious build

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.

Why it breaks

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).

How it breaks in a real browser
  • 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).
  • 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=date and ?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/login sends a user off-origin after sign-in, from a link that looks entirely like yours (Login Redirects and the Open-Redirect Trap).
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

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.
  • URLSearchParams parses and serialises the query correctly, including repeated keys (?tag=a&tag=b yields getAll('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:open and 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 pushState adds 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?).
Where does this value live?

A value must survive a reload and a share. Path segment, query parameter, fragment, or not in the URL at all?

Path segment

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).

Query parameter

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.

Fragment

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.

Not in the URL

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.

One parse, at the boundary
1const SORTS = ['date', 'amount', 'status'] as const
2type Sort = (typeof SORTS)[number]
3
4interface OrderListQuery {
5 page: number
6 sort: Sort
7 status: string[]
8 q: string
9}
10
11const DEFAULTS: OrderListQuery = { page: 1, sort: 'date', status: [], q: '' }
12
13function parseOrderListQuery(url: URL): OrderListQuery {
14 const sp = url.searchParams
15
16 const rawPage = Number(sp.get('page'))
17 const page = Number.isInteger(rawPage) && rawPage >= 1 ? rawPage : DEFAULTS.page
18
19 const rawSort = sp.get('sort')
20 const sort = SORTS.includes(rawSort as Sort) ? (rawSort as Sort) : DEFAULTS.sort
21
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 limits
27 }
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 encoding
38}

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.

The five parameter bugs that reach production
TriggerSymptomCauseResponse
A search box writes ?q= on every keystroke with pushStateBack takes eleven presses to leave the pageOne history entry per character; the browser is faithfully doing what it was toldDebounce, then replaceState while typing. Push once, on submit or on blur, if at all (History and Navigation).
?page=abc from an edited address barA request goes out with page=NaN, or the list renders empty with no messageNumber() coercion with no validation and no fallbackValidate at the boundary, clamp to a legal value, and redirect to the canonical URL with a replace.
Parameters serialised in insertion orderCache 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 viewSerialise 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 stringEverything after the character disappears from the stateManual string building instead of URLSearchParams, so reserved characters are not encodedNever concatenate URLs. Build with URL and URLSearchParams and let them encode.
Canonicalising with pushState on arrivalBack appears broken; one press returns the user to the same screenThe correction pushed an entry, so back goes to the pre-correction URL, which corrects forward againCanonical 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 replaceState for continuous changes — typing, dragging a slider, resizing a pane — and pushState for 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=1 and sort=date are 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=amount while 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

Failure modes
  • Silent coercion. Number(undefined), Number('') and Number('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 %20 becomes %2520 and 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=billing after 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 pushState instead of replaceState creates 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).
What can arrive out of order
  • 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=ca render after the results for ?q=cat (Out-of-Order Responses).
Security
  • 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 Referer header 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).
Misreads
  • "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.
  • "encodeURIComponent everywhere is safe." It is correct for a single component and wrong for a whole URL — encoding an entire URL encodes its separators too. Build with URL and URLSearchParams instead 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

How you would see this
  • 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).
Slow device, slow network, large data, old tab
  • 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).
What this costs
  • 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 searchParams where 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.

Domains that do not exist yet
  • 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.