Navigation

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:

_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

PatternMatchesNotes
"/"The rootPlain string literals.
"/users"Exact pathsStatic segments.
"/users/:id"Path with paramsRead in the arm body via params.id.
"/docs/*"Wildcard suffixThe matched suffix is exposed as params._.
default: / "*"Anything elseFallback 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.

FieldTypeExample value
_route_.pathstring"/users/42"
_route_.params{ [key]: string }{ id: "42" }
_route_.query{ [key]: string }{ sort: "name" }
_route_.patternstring | nullThe route pattern that matched (e.g. "/users/:id").
_route_.navigate(to)fnProgrammatic transition.
breadcrumbs = Breadcrumbs(items: @Split(_route_.path, "/"))
queryString = "Sort: " + (_route_.query.sort || "default")

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 renders an <a> with the correct href, handles the click, and toggles data-active="true" when the URL matches.

PropTypeNotes
labelstringVisible link text.
tostringTarget route, e.g. "/about".
variant"default" | "primary" | "ghost" | "pill"Visual style.
exactbooleanRequire exact path match. Default: prefix match.
iconstringOptional 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.

Next