Routing in one expression.
A single primitive —
_router_({ … }) — turns a flat program
into a multi-page app. The router uses the URL hash, so deep links
and back/forward work without any server config.
The essentials
Routing has three moving parts:
_router_({ "/": Home(), "/about": About(), default: NotFound() })— an expression that returns the page for the current hash.NavLink(label:, to:)— an anchor that navigates on click and reflects the active route._route_— a reactive global with the current path, params, query, hash, and anavigate(target)method.
_app_ = AppShell([sidebar, main])
sidebar = Sidebar([
NavLink(label: "Home", to: "/"),
NavLink(label: "Dashboard", to: "/dashboard"),
NavLink(label: "Settings", to: "/settings")
])
main = _router_({
"/": Home(),
"/dashboard": Dashboard(),
"/settings": Settings(),
default: NotFound()
})
# User-declared page components
component Home() { return Card([CardHeader("Welcome")]) }
component Dashboard() { return Card([CardHeader("Dashboard")]) }
component Settings() { return Card([CardHeader("Settings")]) }
component NotFound() { return Card([CardHeader("Page not found")]) }
Route patterns
| Pattern | Matches | Notes |
|---|---|---|
"/" | The root | Plain string literals. |
"/users" | Exact paths | Static segments. |
"/users/:id" | Path with params | Read in the arm body via params.id. |
"/docs/*" | Wildcard suffix | The matched suffix is exposed as params._. |
default: / "*" | Anything else | Fallback arm. At most one per router. |
Inside a router arm, params is a local variable bound to
an object with the captured segments. Pass it into your page
component:
main = _router_({
"/users": UsersIndex(),
"/users/:id": UserDetail(params),
"/docs/*": DocsPage(params),
default: NotFound()
})
component UserDetail(params) {
return Card([CardHeader("User " + params.id)])
}
component DocsPage(params) {
# params._ contains the wildcard suffix
return Markdown("You're viewing /docs/" + params._)
}
Reading the active route
The _route_ global is a reactive object. Reading any of
its fields registers the current binding as a dependency — the
UI re-renders on every transition.
| Field | Type | Example value |
|---|---|---|
_route_.path | string | "/users/42" |
_route_.params | { [key]: string } | { id: "42" } |
_route_.query | { [key]: string } | { sort: "name" } |
_route_.pattern | string | null | The route pattern that matched (e.g. "/users/:id"). |
_route_.navigate(to) | fn | Programmatic transition. |
breadcrumbs = Breadcrumbs(items: @Split(_route_.path, "/"))
queryString = "Sort: " + (_route_.query.sort || "default")
Navigating programmatically
Call _route_.navigate(target) inside an action:
action openOrder(id) {
_route_.navigate("/orders/" + id)
}
Or skip the action and pass an inline lambda:
Button("Go to dashboard", action: () => _route_.navigate("/dashboard"))
Targets can be absolute ("/orders/42") or query-bearing
("/orders/42?tab=items"). The router resolves them
against the current hash and updates the URL.
NavLink component
NavLink renders an <a> with the
correct href, handles the click, and toggles
data-active="true" when the URL matches.
| Prop | Type | Notes |
|---|---|---|
label | string | Visible link text. |
to | string | Target route, e.g. "/about". |
variant | "default" | "primary" | "ghost" | "pill" | Visual style. |
exact | boolean | Require exact path match. Default: prefix match. |
icon | string | Optional Font Awesome icon name. |
NavLink respects cmd/ctrl/shift-click for opening in a new tab; right-clicks fall through to the native context menu.
App layouts with persistent chrome
Put the router inside a layout container so the chrome (sidebar, header) survives transitions. Only the routed area re-renders.
_app_ = AppShell([
TopBar([
Brand("Acme"),
Group([
NavLink(label: "Home", to: "/"),
NavLink(label: "Reports", to: "/reports"),
NavLink(label: "Settings", to: "/settings")
], align: "end")
]),
Container([page])
])
page = _router_({
"/": HomePage(),
"/reports": ReportsPage(),
"/settings": SettingsPage(),
default: NotFoundPage()
})
Protected routes
Use an expression-form if — gate the entire router on a state atom:
page = if $session.user {
_router_({
"/": HomePage(),
"/admin": AdminPage(),
default: NotFoundPage()
})
} else {
LoginPage()
}
For per-route gating, branch inside the arm:
main = _router_({
"/": HomePage(),
"/admin": if $session.role == "admin" { AdminPage() } else { Forbidden() },
default: NotFoundPage()
})
Listening on the host
Every router transition dispatches a route-change event on the host:
el.addEventListener("route-change", (event) => {
analytics.track("page-view", event.detail);
// event.detail = { path, previousPath, source }
// source is "init" | "hashchange" | "navigate" | "external"
});
Programmatic navigation from the host:
el.navigate("/dashboard");
const current = el.route; // "/dashboard"
Back, forward, deep links
Because the router uses the URL hash (#/about), back
and forward buttons and deep links work automatically — no
server configuration required.
- Reloading
https://example.com/#/users/42mounts the app and immediately routes to/users/42. - Calling
history.back()from the host falls back to the previous hash. - Bookmarks work out of the box.