Routing
Build a single-page router from patchHistory + matches/match + navigate, with same-origin link interception. No router library.
Build a single-page router on top of the platform: history.pushState + URLPattern, wrapped in reactive helpers. You get reactive route matching, named params, and same-origin link interception in about 30 lines.
import { computed, effect } from "elements-kit/signals";
import { patchHistory, matches, match } from "elements-kit/utilities/routing";
patchHistory();
// Real apps match on `pathname`; the hash is used because the preview is a
// sandboxed iframe. `{/}?` also matches the empty hash on first load.
export const isHome = matches({ hash: "{/}?" });
export const userMatch = match({ hash: "/users/:id" });
export const isSettings = matches({ hash: "/settings" });
export const isUser = computed(() => userMatch() != null);
export const isUnknown = computed(
() => !isHome() && !userMatch() && !isSettings(),
);
effect(() => {
const id = userMatch()?.hash.groups.id;
document.title = id
? `User ${id}`
: isHome()
? "Home"
: isSettings()
? "Settings"
: "Not found";
});
The recipe
import { effect } from "elements-kit/signals";
import {
patchHistory,
navigate,
matches,
match,
isLocalNavigationEvent,
} from "elements-kit/utilities/routing";
import { currentLocation } from "elements-kit/utilities/location";
patchHistory(); // call once at boot
const isHome = matches({ pathname: "/" });
const userMatch = match({ pathname: "/users/:id" });
const isSettings = matches({ pathname: "/settings" });
effect(() => {
const id = userMatch()?.pathname.groups.id;
document.title = id ? `User ${id}` : isHome() ? "Home" : "App";
});
document.addEventListener("click", (e) => {
if (!isLocalNavigationEvent(e)) return;
e.preventDefault();
const a = (e.target as Element).closest("a") as HTMLAnchorElement;
navigate(a.href);
});How each piece contributes
| Primitive | Role |
|---|---|
patchHistory() | Monkey-patches pushState / replaceState to dispatch synthetic events. Without it, programmatic navigation is silent. |
matches(pattern) | Computed<boolean> — true when the current URL matches. Use for boolean route gates. |
match(pattern) | Computed<URLPatternResult | null> — full match including pathname.groups for params. |
currentLocation | { href, pathname, search, hash } — each a Computed<string> derived from the patched history events. |
navigate(url, opts?) | Wraps pushState (or replaceState with { replace: true }). |
isLocalNavigationEvent(e) | Predicate: same origin, primary button, no modifiers, no target="_blank" / download. |
Variations
Redirect — replace the entry instead of pushing a new one:
navigate("/login", { replace: true });Read params — match() returns URLPatternResult, so groups are typed as string | undefined:
const route = match({ pathname: "/users/:id/posts/:postId" });
effect(() => {
const r = route();
if (r) console.log(r.pathname.groups.id, r.pathname.groups.postId);
});Route-level data loading — compose with async. The query in Data fetching re-runs whenever its tracked signals change, including currentLocation.pathname() or any match() getter:
const userMatch = match({ pathname: "/users/:id" });
const userQuery = async(() => {
const id = userMatch()?.pathname.groups.id;
if (!id) return null;
return fetch(`/api/users/${id}`).then((r) => r.json());
}).start();Gotchas
Call patchHistory() once, before any navigation
The platform fires popstate only on back/forward — not on pushState / replaceState. patchHistory() adds the synthetic events that currentLocation, matches, and match listen for. Skip it and your router silently stops reacting to programmatic navigation.
URLPattern needs a polyfill on older browsers
Native URLPattern ships in Safari 26+ and Firefox 142+. For wider support, load urlpattern-polyfill before importing the routing utilities.
Use object form for patterns
match("/users/:id") is a relative string pattern and throws without a base URL. Use match({ pathname: "/users/:id" }).
See also
- Routing — full
routingreference (navigate,matches,match,isLocalNavigationEvent). - Data fetching — pair routing with
asyncfor route-level loaders. - Signals —
effectfor side-effects like document title.