Router
The router/* namespace provides an SPA router built on signals. Routes are declared as a map of URL patterns to handler names, and the current route is exposed as a reactive signal, so any component that reads it re-renders on navigation.
Setup
(router/init! routes-or-options) -> nil
Register routes and start listening for URL changes. Two forms are accepted.
A bare map of pattern to handler name:
(router/init! {"/" "home-page"
"/todos" "todo-list"
"/todos/:id" "todo-detail"
"/settings" "settings-page"})Or an options map, which is a map whose :routes key holds a map:
(router/init!
{:mode :hash ;; :hash (default) or :history
:not-found "missing-page" ;; handler for an unmatched path
:scroll-to-top true ;; scroll to the top on navigation
:focus "#main" ;; move focus there on navigation
:routes {"/" "home-page"
"/todos/:id" "todo-detail"}})| Option | Values | Default | Effect |
|---|---|---|---|
:routes | map | required in this form | pattern to handler name |
:mode | :hash, :history | :hash | :history uses real paths via pushState |
:not-found | handler name | none | used when no pattern matches |
:scroll-to-top | true/false | false | scroll to the top after a route change |
:focus | true or selector | none | true returns focus to the document; a selector focuses that element |
The two forms are told apart by what routes holds, not by the key's name: a map means the options form, a handler name string means the ordinary route /routes (a bare table's leading slash is optional, so {"routes" "routes-page"} is just that route).
Calling router/init! again replaces the routes and re-resolves the current URL; the previous listeners are removed, so re-initializing never doubles up handlers.
A pattern may hold any character a URL segment can: {"/søk" "search-page"} and {"/a b" "spaced-page"} match even though the browser stores those URLs percent-encoded (#/s%C3%B8k, #/a%20b).
:mode :history requires the host server to serve the app shell for every route, since a deep link is a real request. :hash requires nothing from the host, which is why it is the default.
Navigation
(router/push! path) -> nil
Navigate to a path, adding a history entry so the back button works.
(router/push! "/todos/42?tab=open")(router/replace! path) -> nil
Navigate without adding a history entry. Useful for redirects.
(router/replace! "/login")(router/back!) -> nil
Go back one entry in the browser history.
A path that leaves the app (https://…, //host, javascript:) is refused rather than followed, and reported through the app's error hook.
Reading the Current Route
(router/current) -> signal-id
Returns the signal ID for the current route match. Use with deref or watch.
(router/current-route) -> map | nil
Convenience wrapper (defined in Sema) that dereferences the route signal. Returns a map with :path, :params, :query, and :handler:
(router/current-route)
;; => {:path "/todos/42" :params {:id "42"} :query {:tab "open"} :handler "todo-detail"}It returns nil only when nothing matched and no :not-found handler is registered.
Route Parameters
Route patterns support named parameters with the :param syntax. Parameters match any non-slash segment and are percent-decoded:
| Pattern | URL | Params |
|---|---|---|
/todos/:id | /todos/42 | {:id "42"} |
/users/:uid/posts/:pid | /users/5/posts/99 | {:uid "5" :pid "99"} |
/ | / | {} |
Parameter names must match [a-zA-Z_][a-zA-Z0-9_]*. Paths are normalized to a leading /, so "todos" and "/todos" name one route.
Query Strings
The query string is parsed into :query and never participates in matching, so a pattern describes the path alone (a ? in a pattern starts a query string there too, and is ignored).
| URL | :query |
|---|---|
/todos?tab=open | {:tab "open"} |
/todos?tag=a&tag=b | {:tag ("a" "b")} |
/todos?draft | {:draft ""} |
/todos?q=hello+world | {:q "hello world"} |
/todos?filter=a=b | {:filter "a=b"} |
A repeated key collects its values into a list, matching how dom/form-data reports repeated field names. A malformed percent escape keeps its raw text instead of failing the route.
Links
(router/link path label attrs) -> SIP
Returns SIP data for an accessible anchor whose clicks are intercepted, so navigation never reloads the page:
[:nav
(router/link "/" "Home" {:class "nav-link"})
(router/link "/todos/42" "Open todo" {:class "nav-link"})]aria-current="page"is added when the link points at the current path (pass your own:aria-currentto override).labelmay be text or SIP data ([:span "Open"]). An absent label falls back to the path, so a link always has an accessible name.- Modified clicks (cmd/ctrl/shift/alt), middle clicks,
:target, and:downloadare left to the browser. - An off-site path renders an inert
<span>instead of a link, and the failure is reported through the app's error hook. "Off-site" covers a scheme (https:,javascript:), a protocol-relative//host, and the backslash spellings of it (/\host,\\host,\host) -- a browser resolves all of those to the same cross-origin URL. The same rule admits paths torouter/push!,router/replace!androuter/href.
(router/href path) -> string | nil
The href a link to path needs in the active mode -- #/todos in hash mode, /todos in history mode. Useful for anchors you build yourself.
Example: Route-Based Rendering
(router/init!
{:not-found "missing-page"
:focus "#main"
:routes {"/" "home-page"
"/about" "about-page"
"/users/:id" "user-page"}})
(defcomponent app-view ()
(let ((r (router/current-route)))
[:main {:id "main"}
[:nav
(router/link "/" "Home" {})
(router/link "/about" "About" {})]
(cond ((equal? (:handler r) "home-page") [:h1 "Home"])
((equal? (:handler r) "about-page") [:h1 "About"])
((equal? (:handler r) "user-page")
[:h1 (string-append "User " (:id (:params r)))])
(else [:h1 "404"]))]))
(mount! "#app" app-view)How It Works
When router/init! is called, it:
- Compiles each pattern into a regex, escaping literal metacharacters and accepting each literal character in both its raw and percent-encoded form (the browser rewrites
#/søkto#/s%C3%B8k, so a pattern that only matched what you wrote would never match what you get back). - Registers a route listener on
window(hashchange, orpopstatein history mode) and one delegatedclicklistener ondocumentfor router links. - Immediately resolves the current URL into the route signal, without running the focus or scroll side effects -- those belong to navigation, not to page load.
Routes are matched in declaration order: the first matching pattern wins. Navigation resolves the new route synchronously, so code that navigates and then reads router/current-route sees the new route, not the previous one.