---
title: 'Client-Side Routing'
source: 'https://academia.sh/en/courses/frontend-architecture/client-side-routing'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# Client-Side Routing

The layer that translates the address into a view; the route table, resolving path patterns, sorting by specificity, parameter extraction, intercepting link clicks, and the responsibilities of the navigation cycle.

The previous course established the component and composition: a unit with inputs, its
own local state, that nests inside other components. Once the component tree is built,
which data flows where is clear. The question left open sits one layer up: **which
component stands at the root of the tree?** The North Slope Measurement Station
application is no longer a single screen. It now has a station list, a station detail
view, a measurement history, and a sign-in screen; something has to decide which one
appears.

The source of this decision is the address. The History and Location lesson in The
Browser and the Web Platform course established that the address can be split into
parts, that the history stack can be changed by the program, and that navigation can
happen without reloading the page. Routing is the decision layer that sits on top of
that mechanism: it takes the address and says which view gets built.

## The Definition of Routing

Routing is the operation that maps an address to a view. Its input is the path portion
of the address; its output has two parts: the component to display and the parameters
to pass to that component.

The server side already does this work; every request is answered by looking at a path.
What is different about client-side routing is that the decision is made **without
reloading the document**. When the user clicks a row in the station list, no new
document is requested; the address changes, the matcher resolves the new path, and part
of the tree is replaced with the new component.

This is not only about a lower navigation cost. Because the document stays in place,
state held in memory survives: a half-filled form, an open panel, a downloaded data set
all persist across navigation. The cost is that work the browser once did on its own
becomes the page's responsibility — scroll position, focus management, request
cancellation, the tab title. Each is addressed in turn over this topic.

## The Route Table

Routing's data is a **route table**: a list of mappings between a path pattern and a
view. A path pattern is made of static segments, parameter segments, and a wildcard.

`/station` is a static segment; it matches only itself. `/:id` is a parameter segment;
it matches any segment and binds the matched value to a name. `/*` is the wildcard; it
consumes every remaining segment and is usually bound to a not-found view.

There is a point where the table alone is not enough. The path `/station/new` matches two
patterns at once: the static `/station/new` and the parameterized `/station/:id`. Which
wins? The answer cannot be declaration order — then row position would carry meaning,
and the bug would surface silently the moment two developers disturbed that order. The
answer is **specificity**: the pattern accepting fewer paths is tried first.

```js
// route-matcher.mjs — resolving the path pattern, sorting by specificity, parameter extraction
const WEIGHT = { static: 3, parameter: 2, wildcard: 1 };

function resolvePattern(pattern) {
  const segments = pattern.split("/").filter(Boolean).map((p) =>
    p === "*" ? { type: "wildcard" }
      : p.startsWith(":") ? { type: "parameter", name: p.slice(1) }
        : { type: "static", value: p });
  return {
    pattern, segments,
    score: segments.map((p) => WEIGHT[p.type]),
    wildcard: segments.some((p) => p.type === "wildcard") ? 1 : 0,
  };
}

function compareSpecificity(a, b) {
  if (a.wildcard !== b.wildcard) return a.wildcard - b.wildcard;      // wildcard pattern goes last
  const common = Math.min(a.score.length, b.score.length);
  for (let i = 0; i < common; i++)                         // the first differing segment decides
    if (a.score[i] !== b.score[i]) return b.score[i] - a.score[i];
  return b.score.length - a.score.length;                   // longer pattern first
}

function match(route, path) {
  const segments = path.split("/").filter(Boolean);
  const params = {};
  for (let i = 0; i < route.segments.length; i++) {
    const p = route.segments[i];
    if (p.type === "wildcard") { params.rest = segments.slice(i).join("/"); return params; }
    if (i >= segments.length) return null;
    if (p.type === "static") { if (p.value !== segments[i]) return null; }
    else params[p.name] = decodeURIComponent(segments[i]);
  }
  return segments.length === route.segments.length ? params : null;
}

// Declaration order deliberately broken: ":id" is more general yet written before "new".
const declarationOrder = [
  "/station/:id",
  "/station/new",
  "/*",
  "/station/:id/measurements/:time",
  "/",
  "/station",
  "/station/:id/measurements",
];
const table = declarationOrder.map(resolvePattern).sort(compareSpecificity);

console.log("-- table sorted by specificity --");
for (const r of table) console.log((r.score.join(",") || "-").padEnd(9), r.pattern);

console.log("-- matches --");
for (const path of ["/", "/station", "/station/new", "/station/north-slope",
  "/station/north-slope/measurements", "/station/north-slope/measurements/2024-02-11T06:00",
  "/station/north%20slope", "/settings/privacy"]) {
  let found = null, params = null;
  for (const r of table) {
    const p = match(r, path);
    if (p) { found = r.pattern; params = p; break; }
  }
  console.log(path.padEnd(44), found.padEnd(30), JSON.stringify(params));
}
```

```
-- table sorted by specificity --
3,3       /station/new
3,2,3,2   /station/:id/measurements/:time
3,2,3     /station/:id/measurements
3,2       /station/:id
3         /station
-         /
1         /*
-- matches --
/                                            /                              {}
/station                                     /station                       {}
/station/new                                 /station/new                   {}
/station/north-slope                         /station/:id                   {"id":"north-slope"}
/station/north-slope/measurements            /station/:id/measurements      {"id":"north-slope"}
/station/north-slope/measurements/2024-02-11T06:00 /station/:id/measurements/:time {"id":"north-slope","time":"2024-02-11T06:00"}
/station/north%20slope                       /station/:id                   {"id":"north slope"}
/settings/privacy                            /*                             {"rest":"settings/privacy"}
```

The sort produces an order independent of declaration order. `/station/new` rises to
the top; the path `/station/north-slope` does not match it, so it falls to the
parameterized pattern and the ID is extracted correctly. Because the wildcard is pushed
to the end, it never shadows a real route.

There are three more details worth reading in the match table. The root path's pattern
contains no segments at all and matches only itself. A parameter's value is delivered
with the **escaping resolved** in the address: the segment `north%20slope` becomes
`north slope`; a matcher that skips this step delivers IDs containing spaces or
non-ASCII characters corrupted. An undefined path falls to the wildcard and carries what
was requested in the `rest` field; the not-found view can show this information to the
user.

One rule is set up implicitly: a parameter segment consumes **one** segment. The path
`/station/a/b` does not match the pattern `/station/:id`, because the segment counts
do not line up. If a value containing a slash needs to be carried, either the wildcard is
used, or the value is escaped to fit a single segment.

## The Navigation Cycle

The matcher is a function; routing itself is a cycle that runs continuously. The cycle
has four steps, and each step carries a separate responsibility.

**The address changes.** This has three sources: the user clicking a link, the program
calling navigation, or the user pressing the back or forward button. In the first two,
code changes the address; in the third, the browser changes it and notifies the code.

**The matcher runs.** The new path is looked up in the route table, and the winning
pattern and its parameters are extracted.

**The view is built.** The winning route's component is placed in the tree, and the
parameters are passed to it.

**Edge responsibilities are carried out.** Scroll position is set, focus moves to the
new content, the tab title is updated, and the previous view's requests are canceled.

The fourth step is the part of the cycle most often skipped. In a real navigation, the
browser does all this on its own; in client-side navigation, none of it happens, since
the document never changes. Focus not moving is an especially quiet defect: someone
using a keyboard or a screen reader presses a link and does not notice the page changed,
because focus stays put.

## Intercepting a Link

The starting point of client-side navigation is **canceling the link click's default
action**. The Default Behavior and Cancellation lesson in The Document and Events topic
established this mechanism: when an event is cancelable, the browser's own behavior can
be stopped.

Not every click is intercepted. The cases that must not be are well defined and tested
with a chain of conditions. A link to a different origin is not intercepted; the
application cannot display that address anyway. A download link, a link requested to
open in a new tab, and a link opened with a modifier key are also left alone. Actions
taken through a right-click context menu do not produce the event at all.

When these conditions are forgotten, the defect is always the same: the user loses a
familiar browser behavior. Not being able to open a link in a new tab, copy the address,
or use a download link all trace back to this single omission.

After the click is canceled, the work that follows is pushing a new entry onto the
history and starting the cycle. The back button runs the same cycle without pushing an
entry: the browser produces a navigation event, the code listens for it, and calls the
matcher with the new path.

## The Address as the Single Source of Truth

Once routing is set up, an application can end up with two separate pieces of "which
screen are we on" information: the address and a local variable. Kept separate, the two
inevitably drift apart — the back button is pressed, the address changes, but the
variable keeps its old value, or the reverse happens.

The rule is this: **the active view is derived from the address, not held separately in
memory.** Going to a screen means changing the address, not setting a variable. The view
is the result of that change.

This rule gives a test. From any screen in the application, copying the address bar's
address and opening it in a new tab should bring up the same screen. If it does not, some
part of that screen was never written into the address.

The counterpart to this rule sits on the server side, established in the History and
Location lesson: every address the client produces must get a valid response when
requested directly. A server that maps defined path patterns to a single document
provides this; a genuinely missing address still returning a not-found response is part
of the same contract.

## Summary

- Routing is the decision layer that maps the path portion of the address to a view and
  a set of parameters; on the client side, this decision is made without reloading the
  document.
- The route table is built from static, parameter, and wildcard segments; matching
  ambiguity is resolved by specificity order, not declaration order, and the wildcard is
  always pushed to the end.
- A parameter segment consumes a single segment, and its value is delivered with the
  escaping resolved.
- Navigation is a cycle: the address changes, the matcher runs, the view is built, and
  edge responsibilities such as scroll, focus, title, and cancellation are carried out.
- A link click's default action is canceled only in cases the application can genuinely
  take over; external addresses, downloads, and new-tab requests are left to the
  browser.
- The active view is derived from the address; holding the same information separately
  in memory produces two sources of truth that drift apart with the back button.

## Next Step

This lesson's matcher worked on a flat table: each path bound to a single view. The
application's screens, though, share a common shell. The station detail view and the
measurement history show the same header, the same side navigation, and the same
station profile; only the middle region changes. With a flat table, this shared shell
gets rebuilt and repainted on every route, and the state inside it resets on every
navigation. The next lesson covers defining routes as a nested tree, producing a layout
chain from the matching branch, and why the shell is preserved during navigation.
