---
title: 'Route Parameters and Query'
source: 'https://academia.sh/en/courses/frontend-architecture/route-parameters-and-query'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Route Parameters and Query

Carrying state in the address; separating the path parameter from query state, reading and validating through a schema, omitting defaults from the address, canonical form, resetting dependent parameters, and the history entry decision.

The route tree delivered the state carried by the path to the components: which
station, which section. The rest of the measurement history screen's state, though,
is not in the address yet. A date range might be selected, a measurement type filtered,
the list on its second page, sorted descending by date.

When these live in the component's local variables, the user cannot share that view,
bookmark it, or return to the previous filter with the back button. Written into the
address, though, the address grows long, gets cluttered, and every change becomes a
candidate for pushing a new history entry. This lesson separates two questions: which
state enters the address, and how the state that enters gets written.

## Path, Query, or Memory

The History and Location lesson separated the address's parts by the kind of state they
carry: the path carries the resource's identity, the query carries which view of that
same resource is requested, and the fragment carries a position within the document.
Routing turns this distinction into an actionable rule.

A **path parameter** selects a resource. The station ID belongs in the path, because
when it changes, what is displayed is a different object. A path parameter has no
default: the resource is either specified or it is not.

A **query parameter** adjusts the presentation of the same resource. Filters, sorting,
the page number, and the display format belong here. Each one has a default, and the
screen still makes sense when the user has selected none of them.

**Memory**, meanwhile, holds state that has no place in the address. The History and
Location lesson supplied the test: does the user expect to see the same screen when
this address is opened in a new tab? An open tooltip, a slider's momentary value while
being dragged, an animation's progress all fail this test.

There is a borderline case: information meaningful only for this session but that needs
to survive across navigation — the last row viewed in a list, for instance. This
belongs in the history entry's state object; it is not written to the address, but it
comes back when the user navigates back.

## The Path Parameter Is External Input

A path parameter is a field the user can write into. It can be typed into the address
bar by hand, come from an old bookmark, or have been generated incorrectly in a link.
The matcher having extracted it does not mean it is valid.

This holds on the security side too. A user who can open `/station/north-slope` can
just as well open `/station/east-ridge`. The client validating the parameter catches
format errors; only the server can validate authorization. This distinction is the
subject of the next lesson.

Validation has three distinguishable outcomes. A malformed parameter — a letter where a
number was expected — falls to the not-found view. A well-formed parameter with no
matching resource is discovered through the server's response and also produces
not-found. A valid parameter is passed on to the view.

## The Schema of Query State

The query string is a string map: every value is text, every name can appear more than
once, and its content comes from outside. These three properties make it impossible to
use the query directly as state. A **schema** sits between them: a definition that
declares each parameter's type, default, and valid value set.

```js
// query-state.mjs — reading the query string through a schema and writing it in canonical form
const SCHEMA = {
  measurement: { type: "choice", default: "all", options: ["all", "temperature", "humidity", "wind"] },
  threshold: { type: "number", default: null },
  page: { type: "integer", default: 1, min: 1 },
  sort: { type: "choice", default: "descending", options: ["ascending", "descending"] },
  tag: { type: "multi", default: [] },
};

// Single validation point: both reading and writing pass through this.
function validate(rule, raw) {
  if (rule.type === "choice")
    return rule.options.includes(raw) ? { value: raw } : { invalid: true };
  const n = rule.type === "integer" ? Number.parseInt(raw, 10) : Number(raw);
  if (!Number.isFinite(n) || (rule.min !== undefined && n < rule.min)) return { invalid: true };
  return { value: n };
}

function read(query) {
  const p = new URLSearchParams(query);
  const state = {}, warnings = [];
  for (const [name, rule] of Object.entries(SCHEMA)) {
    if (rule.type === "multi") {
      const all = p.getAll(name);
      state[name] = all.length ? [...new Set(all)].sort() : rule.default;
      continue;
    }
    const raw = p.get(name);
    if (raw === null) { state[name] = rule.default; continue; }
    const result = validate(rule, raw);
    if (result.invalid) { state[name] = rule.default; warnings.push(`${name}=${raw} invalid`); }
    else state[name] = result.value;
  }
  return { state, warnings };
}

function write(state) {
  const p = new URLSearchParams();
  for (const [name, rule] of Object.entries(SCHEMA)) {     // schema order gives the canonical order
    const value = state[name];
    if (rule.type === "multi") {
      for (const d of [...new Set(value ?? [])].sort()) p.append(name, d);
      continue;
    }
    if (value === null || value === undefined || value === rule.default) continue;
    if (validate(rule, String(value)).invalid) continue;   // invalid value is not written to the address
    p.set(name, String(value));
  }
  const s = p.toString();
  return s ? `?${s}` : "";
}

console.log("-- reading --");
for (const query of [
  "",
  "?measurement=temperature&page=3",
  "?page=abc&measurement=snow&threshold=-3.5&tag=night&tag=snow&tag=night",
  "?page=0&sort=ascending&measurement=relative+humidity",
]) {
  const { state, warnings } = read(query);
  console.log((query || "(empty)").padEnd(66), JSON.stringify(state));
  if (warnings.length) console.log(" ".repeat(66), "warnings:", warnings.join("; "));
}

console.log("-- writing --");
for (const state of [
  { measurement: "all", threshold: null, page: 1, sort: "descending", tag: [] },
  { measurement: "temperature", threshold: -5, page: 1, sort: "descending", tag: [] },
  { measurement: "relative humidity", threshold: null, page: 4, sort: "ascending", tag: ["snow", "night"] },
]) console.log(JSON.stringify(state).padEnd(94), write(state) || "(empty)");

console.log("-- round trip --");
const initial = { measurement: "wind", threshold: 12.5, page: 2, sort: "ascending", tag: ["night"] };
const str = write(initial);
console.log("string  ", str);
console.log("same     ", JSON.stringify(read(str).state) === JSON.stringify(initial));
console.log("stable   ", write(read(str).state) === str);

console.log("-- on the address --");
const address = new URL("https://example.test/station/north-slope/measurements?page=9&measurement=humidity");
const updated = { ...read(address.search).state, measurement: "temperature", page: 1 };
console.log(address.pathname + write(updated));
```

```
-- reading --
(empty)                                                            {"measurement":"all","threshold":null,"page":1,"sort":"descending","tag":[]}
?measurement=temperature&page=3                                    {"measurement":"temperature","threshold":null,"page":3,"sort":"descending","tag":[]}
?page=abc&measurement=snow&threshold=-3.5&tag=night&tag=snow&tag=night {"measurement":"all","threshold":-3.5,"page":1,"sort":"descending","tag":["night","snow"]}
                                                                   warnings: measurement=snow invalid; page=abc invalid
?page=0&sort=ascending&measurement=relative+humidity               {"measurement":"all","threshold":null,"page":1,"sort":"ascending","tag":[]}
                                                                   warnings: measurement=relative humidity invalid; page=0 invalid
-- writing --
{"measurement":"all","threshold":null,"page":1,"sort":"descending","tag":[]}                   (empty)
{"measurement":"temperature","threshold":-5,"page":1,"sort":"descending","tag":[]}             ?measurement=temperature&threshold=-5
{"measurement":"relative humidity","threshold":null,"page":4,"sort":"ascending","tag":["snow","night"]} ?page=4&sort=ascending&tag=night&tag=snow
-- round trip --
string   ?measurement=wind&threshold=12.5&page=2&sort=ascending&tag=night
same      true
stable    true
-- on the address --
/station/north-slope/measurements?measurement=temperature
```

The reading side does not fail on any input. A missing parameter falls to its default, a
malformed number falls to its default and produces a warning, an undefined option is
rejected. This behavior is called **robustness**: an old address a user still has lying
around opens a meaningful view instead of dropping the application into an error
screen.

Two decisions show up on the writing side. A value equal to the default is **not
written to the address**; in the first row, every field is a default, so the query is
empty. This does more than keep the address short: the same screen is always referred
to by a single address. An invalid value is not written either; in the third row, the
undefined measurement type is dropped and the remaining fields are written.

## Canonical Form

The same state having exactly one string representation is called **canonical form**.
The writer in the example achieves this with two rules: parameters are written in
schema order, and multi-valued sets are sorted within themselves. This is why the tag
set `snow, night` shows up in the output in the order `night, snow`.

The round-trip section tests two properties. Reading is the inverse of writing: state
that is written, when read back, produces the same object. Writing is stable: state
that is read back, when written again, produces the same string. Together, the two say
that the address is a reliable carrier of state.

Canonical form pays off in three places. A server state cache can use the address as
its key; because the same view is never referred to by two different strings, no
duplicate cache entries appear. Whether two addresses show the same screen can be
determined by a plain string comparison. And addresses shared by different users end up
identical.

## Dependent Parameters

The final section is an example of a decision. With page nine and the humidity filter
in the address, the measurement type is switched to temperature; the output carries no
page number, because it was explicitly reset to one.

When a filter changes, the old page number loses its meaning: page nine might not exist
under the new filter, and the user sees an empty list. The same dependency holds for a
selected row's ID, an infinite list's loaded length, and a position within a sort.
These resets do not happen on their own; which parameter invalidates which is written
down explicitly.

The reverse direction matters just as much: **an unrelated parameter is preserved**.
While the measurement type changes, the sort and the threshold must stay in place. Code
that concatenates the query string by hand loses this protection; code that reads the
state as a whole and writes over it keeps it.

## Pushing a History Entry or Replacing One

Every change to query state changes the address; not every change, though, has to push
a new entry onto the history. The distinction established in the History and Location
lesson turns into a decision here.

**Pushed:** changes the user would want to return to with the back button. Moving
between pages, changing the measurement type, reversing the sort.

**Replaced:** intermediate states and corrections the user does not expect to return to.
A search field that pushes an entry for every character typed turns the back button
into a mechanism that undoes typing letter by letter. Fields like this replace during
typing; combined with the debounce from the Form Events lesson, the address updates
once after typing stops.

The correction made at the application's startup also uses replace: if the address
holds an invalid value, it is pulled into canonical form, but this correction leaves no
history entry. If it did, the back button would take the user back to the invalid
address, and a loop would form.

## Summary

- A path parameter selects a resource and has no default; a query parameter adjusts
  the same resource's presentation and each one has a default; the rest of the state
  stays in memory or in the history entry's state object.
- The path parameter is external input; format validation happens on the client, but
  authorization validation cannot be left to the client. Query state is likewise read
  through a schema: missing, malformed, and undefined values fall to their defaults
  instead of sending the application to an error screen.
- Values equal to the default and invalid values are not written to the address; fixing
  the parameter order and the order of multi-valued sets produces canonical form.
- Canonical form makes it possible to produce a cache key, compare two views, and keep
  shared addresses identical.
- If one parameter invalidates another, the reset is written down explicitly; unrelated
  parameters are preserved.
- Changes the user expects to return to are pushed onto the history; intermediate
  states and startup corrections replace the current entry.

## Next Step

The address now carries, in full, which view of which resource is requested. The one
thing it does not carry is whether the user **has the right** to see that resource. The
station settings screen is open only to authorized users; measurement entry only to
signed-in users. A path typed by hand into the address bar tries to open these screens
too. The next lesson covers access conditions tied to a route, turning a condition into
a decision, returning to the requested address after signing in, and why client-side
enforcement is not a security boundary.
