0011: Router primitives
Status: Accepted Date: 2026-05-10
Context
ADR 0009 exposes the incoming Request
via getRequest() and the SSR example refactor in the same iteration
showed a typical use:
const req = getRequest();
const path = req ? new URL(req.url).pathname : window.location.pathname;
if (path === '/') return HomePage();
if (path === '/about') return AboutPage();
...
That dispatch is correct but every Purity user shipping more than one route will reinvent it. Three pieces of boilerplate recur:
- Cross-target path reading. Every component that wants the path
must branch on "server (via getRequest) vs client (via
window.location)". Easy to get subtly wrong — e.g., the server
branch missing the
URLwrap, or the client branch not being reactive so navigation doesn't trigger a re-render. - History API integration. Client-side soft navigation needs
pushState+ apopstatelistener + a reactive signal that re-renders watchers when either fires. The pieces are small; the glue is annoying. - Pattern matching. Even tiny apps want
/users/:idor/blog/*style captures. Hand-rolledsplit('/')matchers are ten-line snippets that everyone writes slightly differently.
A full file-system router is its own ADR (substantial — needs Vite plugin scanning, layout nesting, route trees). But three small primitives cover ~80% of the value at ~80 lines of code:
- A reactive
currentPath()that works on server and client. - A
navigate(href)that updates History and the reactive signal. - A
matchRoute(pattern)that handles:param+*splat.
These compose to a hand-rolled router (one matchRoute call per
route entry) without locking the user into a Purity-specific routing
convention.
Decision
Ship three router primitives in @purityjs/core: currentPath(),
navigate(href, options), and matchRoute(pattern, path?). No
file-system convention, no <Route> component, no route tree —
those land in a future ADR if the demand is there. Phase 1 is the
minimum that closes the per-app boilerplate.
import { currentPath, matchRoute, navigate, html } from '@purityjs/core';
function App() {
if (matchRoute('/')) return HomePage();
const m = matchRoute('/users/:id');
if (m) return UserPage(m.params.id);
return NotFound();
}
html`<a
href="/about"
@click=${(e) => {
e.preventDefault();
navigate('/about');
}}
>About</a
>`;
Concretely:
currentPath(): string— reactive accessor for the current URL pathname.- On the server (inside a
getSSRRenderContext()-bearing call), readsnew URL(request.url).pathnamefrom the SSR-suppliedRequest. Returns'/'when no request was supplied. - On the client, reads a module-scoped signal initialised from
window.location.pathnameat module load. The signal is kept in sync withpopstateevents andnavigate()calls. ReadingcurrentPath()from inside awatch()/ reactive template subscribes to changes.
- On the server (inside a
navigate(href, options?: { replace?: boolean }): void— pushState + update the reactive signal. No-op on the server. Same- origin only (cross-origin hrefs are silently ignored; callers should setwindow.locationdirectly for full-page nav). Default ispushState;{ replace: true }usesreplaceStateso the back-stack isn't extended.matchRoute(pattern, path?): { params } | null— pattern matcher. Grammar:- Literal segments —
/about,/users/edit :name— captures one path segment intoparams.name, URI-decoded soAda%20Lovelace→'Ada Lovelace'*— matches the remainder; captured underparams['*']Returnsnullon miss (including paths with trailing segments the pattern doesn't consume —/aboutdoesn't match/about/x). Path defaults tocurrentPath()so calls inside a reactive context auto-track.
- Literal segments —
Three exports total. No new types beyond NavigateOptions and
RouteMatch. ~80 LOC of implementation. Compiled into the
client bundle only when imported.
Explicit non-features
- No
<Route>/<Routes>component or route tree. Users writeif (matchRoute(...)) return …in the body of theirApp(). The trade is "less magic, more lines for very large apps." A future ADR can layer a<Routes>helper on top of these primitives if needed. - No link interception. Each
<a href>opts into client-side nav by handling@clickand callingnavigate(). A global click listener that converts every internal<a>to a soft nav is convenient but adds nontrivial behavior (modifier keys, target attribute, download links, full-page reloads on form submit, …); defer to a follow-up. - No layout/nesting primitive. Single flat route table.
- No data loaders / pending UI. That space is already covered by
resource()+suspense(); the router doesn't try to re-invent them. - No URL search / hash signals.
currentPath()is just the pathname. Reading search / hash means constructingnew URL(window.location.href)directly. Reactivity for search- param changes can come in a follow-up if there's demand. - No filesystem-based route discovery. The big one — a
pages/-style convention with file scanning + nested layouts — is a separate ADR.
Consequences
Positive:
- Closes the per-app routing boilerplate without picking a routing convention. Users on edge runtimes, traditional Node servers, SSG, or pure client SPAs all use the same three functions.
- Server / client parity.
currentPath()returns the same value at the same render point regardless of execution context, so SSR'd markup matches what the client will produce on first hydrate. - No bundle cost for apps that don't import these. Tree-shakeable.
- Composes with the rest:
getRequest()for header / cookie reads,head()for per-route<title>,matchRoute()for dispatch. Combined output is a small, complete app shell.
Negative:
- Manual route table. A 30-route app writes 30
if (matchRoute(…))branches. Annoying at scale, fine until the demand for a declarative route tree justifies the next ADR. - Manual
@clickinterception. Every<a>that wants soft-nav adds six lines of event handling. Boilerplate, but explicit boilerplate beats magic that breaks modifier-clicks. - Path-only matching. Pattern can't constrain on search params or
hash; users do that with
new URL(req.url)and an extra conditional. - No middleware / guards. Auth checks happen in the route handler
(
if (!loggedIn) return SignInPage()).
Neutral:
- Three exports added to the public surface of
@purityjs/core. Tree-shaken when unused. matchRoutereturns itsparamsas a plainRecord<string, string>. Typed inference per-pattern would need generics gymnastics that hurt readability; users cast or assert where types matter.
Alternatives considered
Ship a full SolidStart-style router with <Routes>, <Route>,
nested layouts, loaders, and link interception. Rejected for Phase 1
scope. The right shape for a Purity router will become clearer once
the framework has a file-system convention and a layout primitive;
shipping a router first would commit to a layout shape before the
right shape is known.
Provide only currentPath(); leave matchRoute() and navigate()
to userland. Rejected: navigate() is too easy to write wrong (the
popstate listener is the bit users forget), and matchRoute() is
small enough to ship correctly once. Three primitives is the right
unit.
Return a URL from currentLocation() instead of a string from
currentPath(). Tempting — URL exposes pathname, search, and hash
in one go. Rejected because URL construction is non-trivial on hot
paths (each call allocates) and the 95% case wants just the
pathname. If search-param reactivity becomes a thing, add it as
separate currentSearch() / currentHash() accessors so callers
opt in.
Auto-intercept all internal <a> clicks via a global listener.
Convenient but subtly behavior-breaking — modifier keys (cmd+click
to open in new tab), target="_blank", download links, in-page hash
anchors, and <form> submissions all need careful exemption.
Defer to a follow-up that gets the precedence right.
Use the History API directly without a reactive signal layer. Rejected: components reading the path inside reactive templates need a way to subscribe to changes. The signal is the cleanest primitive for that on Purity's reactivity model.