Advanced

Routing.

Client‑side, hash‑based routing in a single function call. $router({ … }) maps URL patterns to UI, NavLink navigates, and the reactive route handle tells you where you are. Deep links and back/forward work with no server config — the router lives entirely in the URL hash.

The Router

$router is a plain function call: hand it an object that maps URL patterns to UI, assign it to a binding, and reference that binding from your shell. Only the arm matching the current hash runs — the others are never evaluated.

pages = $router({
  "/":             Home(),
  "/users/:id":    User(params.id),
  "/docs/*":       Docs(params._),
  default:         NotFound()
})

$app(AppShell(MainSidebar(), pages))

The keys are matched in order; pick the one that fits the URL:

PatternMatchesExposes
"/"The root path.
"/users"An exact, static path.
"/users/:id"A dynamic segment/users/42, /users/annaparams.id (e.g. "42")
"/docs/*"A wildcard — everything under /docs/.params._ (the matched suffix)
default:Anything that matched nothing above.

Inside an arm, params is in scope automatically — you never declare it. Pass the captured values straight into your page component, exactly as shown above with User(params.id) and Docs(params._). Omit the default: arm and an unknown path simply renders nothing.

NavLink(label, { to }) renders a router‑aware anchor: it sets the correct href, intercepts the click to navigate without a reload, and auto‑highlights itself when the current path matches to. Drop a row of them anywhere for a nav bar.

nav = Row([
  NavLink("Home",  { to: "/", exact: true }),
  NavLink("Users", { to: "/users" })
], { gap: "sm" })

By default the active check is a prefix match, so the "Users" link stays highlighted on /users/42 too. The "Home" link uses exact: true — otherwise "/" is a prefix of every path and it would always look active. NavLink also accepts an icon and a variant ("default" | "primary" | "ghost" | "pill").

The route handle

route is a reserved, reactive handle that is always in scope. Reading any field registers a dependency, so your UI re‑renders on every transition. Never declare a state slot named route yourself.

FieldTypeExample
route.pathstring"/users/42"
route.params{ [key]: string }{ id: "42" }
route.query{ [key]: string }{ sort: "name" } for ?sort=name
route.patternstring | nullThe arm that matched, e.g. "/users/:id"
route.navigate("/path")fnProgrammatic transition.
label = "Sorted by " + (route.query.sort || "default")

Call route.navigate("/path") from inside an action — for example after saving a record, to send the user to its detail page:

function saveOrder() {
  $orders.refetch()
  route.navigate("/orders/" + $newId)
}

Or inline, straight off a button:

Button("Open dashboard", { onClick: () => route.navigate("/dashboard") })

Targets can be absolute ("/orders/42") or carry a query string ("/orders/42?tab=items"); the router updates the hash and the route handle reactively.

App shell

Wrap the router in AppShell(sidebar, content) so the chrome survives transitions — only the routed pages area re‑renders. Build the sidebar from SidebarSections of SidebarItem(label, { to, icon?, badge? }), which navigate and auto‑highlight just like NavLink.

pages = $router({
  "/":          Home(),
  "/projects":  Projects(),
  "/calendar":  Calendar(),
  default:      NotFound()
})

nav = Sidebar([SidebarSection("Workspace", [
  SidebarItem("Home",     { to: "/",         icon: "house" }),
  SidebarItem("Projects", { to: "/projects", icon: "folder", badge: "4" }),
  SidebarItem("Calendar", { to: "/calendar", icon: "calendar" })
])])

$app(AppShell(nav, pages))

Nested & layout routes

Real apps share chrome across a group of pages — a settings shell, a dashboard with a secondary nav. Instead of repeating that frame in every arm, give the router a layout arm: an object shaped { layout, routes }. The key matches as a prefix; the layout function receives an outlet (the matched child) and renders it wherever it likes; routes is a nested map matched against the remaining path. Parent and child params merge, and layouts nest arbitrarily deep.

function SettingsShell(outlet) {
  return Row([
    Sidebar([SidebarSection("Settings", [
      SidebarItem("Profile", { to: "/settings/profile" }),
      SidebarItem("Billing", { to: "/settings/billing" })
    ])]),
    Column([ outlet ])          // ← matched child renders here
  ], { gap: "lg" })
}

pages = $router({
  "/": Home(),
  "/settings": {
    layout: SettingsShell,
    routes: {
      "/profile":      ProfilePage(),
      "/billing/:id":  BillingPage(params.id)   // params merge parent + child
    }
  },
  default: NotFound()
})

Navigating between /settings/profile and /settings/billing/42 keeps SettingsShell mounted — only the outlet swaps. This is the composition behind multi‑level navs without prop‑drilling a “current section” flag.

Navigation guards

Register a guard with $util.onNavigate(fn). The guard receives { to, from } and returns one of three things:

ReturnEffect
falseBlock the navigation — the URL stays put.
a path stringRedirect to that path instead.
anything elseAllow the navigation.
$util.onNavigate(({ to, from }) => {
  if (to.startsWith("/admin") && !$user.isAdmin) return "/login"   // redirect
  if (to === "/checkout" && $cart.length === 0)   return false      // block
  return true                                                       // allow
})

Guards are enforced everywhere navigation can originate: in‑app route.navigate(…) / NavLink, the browser back/forward buttons, and manual edits to the URL (a blocked or redirected change rewinds the address bar). Guards fail open — if yours throws, navigation proceeds — and a redirect loop is capped. Call $util.onNavigate(null) to clear.

Query params ↔ state

Read query parameters reactively through route.query or $util.url.query. To write them — so a filter, tab, or sort survives a refresh and is shareable — use the $util.url helpers, which update the URL in place (history and hash modes both supported) and re‑render:

CallEffect
$util.url.setQuery("tab", "billing")Set one param. A null / "" value drops the key.
$util.url.setQuery({ sort: "name", page: 2 })Set several at once.
$util.url.removeQuery("page")Drop a key.
$util.url.navigate("/path")Imperative navigation (same as route.navigate).
$tab = route.query.tab || "overview"

nav = Row([
  Button("Overview", { onClick: () => $util.url.setQuery("tab", "overview") }),
  Button("Billing",  { onClick: () => $util.url.setQuery("tab", "billing") })
])
// the URL becomes ?tab=billing — reload-safe and shareable

Route transitions

Wrap your routed pages in RouteView to replay a CSS entrance animation on every navigation. It swaps a keyed wrapper keyed on route.path, so the fresh page re‑runs its entrance — no JavaScript timing, and it respects prefers-reduced-motion.

pages = $router({ "/": Home(), "/about": About(), default: NotFound() })

$app(AppShell(nav,
  RouteView(pages, { routeKey: route.path, animation: "fade", duration: 250 })
))
// animation: "fade" | "zoom" | "slide"

Prefetch on hover

Warm a route’s data before the click. Pass a prefetch callable to NavLink — it fires once on the first pointer‑enter or focus, priming the target route’s $query cache so the page paints instantly when the user commits.

NavLink("Reports", {
  to: "/reports",
  prefetch: () => $query({ url: API + "/reports", key: "reports" })
})

Lazy route loading

Router arms evaluate lazily — an arm’s tree is only built when its path matches. For code that should be downloaded on demand, wrap an async chunk in Lazy(loader, fallback?): it renders the fallback until the dynamic import resolves, then swaps in the real view. Combine with the module loader to split large pages out of the initial bundle.

pages = $router({
  "/":        Home(),
  "/reports": Lazy(() => import("./reports.aktion"), Spinner()),
  default:    NotFound()
})

Scroll restoration

Opt in with the scroll-restoration attribute on the host <aktion-app> element. The runtime saves each page’s scroll position on the way out and restores it on back/forward, while a fresh forward navigation scrolls to the top.

<aktion-app scroll-restoration="auto"></aktion-app>
<!-- "auto" → restore on back/forward, top on fresh nav
     "top"  → always scroll to top on every navigation -->

Tabs vs routing

Routing is for distinct pages with their own URLs — things you want to deep‑link, bookmark, and reach with back/forward. For switching between sections of the same page that should not change the URL, reach for Tabs instead. It keeps the selection in the component and never touches the hash, so it is safe to use many times on one screen.

Live
$app(Column([
  Tabs([
    TabItem("overview", { label: "Overview", children: [Text("Sections, not pages — the URL never changes.")] }),
    TabItem("activity", { label: "Activity", children: [Text("Switching tabs keeps you on the same route.")] }),
    TabItem("settings", { label: "Settings", children: [Text("Reach for Router when each view needs its own URL.")] })
  ])
]))

Next