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:
| Pattern | Matches | Exposes |
|---|---|---|
"/" | The root path. | — |
"/users" | An exact, static path. | — |
"/users/:id" | A dynamic segment — /users/42, /users/anna… | params.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.
Navigating with NavLink
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.
| Field | Type | Example |
|---|---|---|
route.path | string | "/users/42" |
route.params | { [key]: string } | { id: "42" } |
route.query | { [key]: string } | { sort: "name" } for ?sort=name |
route.pattern | string | null | The arm that matched, e.g. "/users/:id" |
route.navigate("/path") | fn | Programmatic transition. |
label = "Sorted by " + (route.query.sort || "default")
Imperative navigation
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:
| Return | Effect |
|---|---|
false | Block the navigation — the URL stays put. |
a path string | Redirect to that path instead. |
| anything else | Allow 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:
| Call | Effect |
|---|---|
$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.
$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
Actions
Wire up onClick, forms, and route.navigate from event handlers.
Component catalog
AppShell, Sidebar, NavLink, Tabs — every prop with a live preview.
Browse components → Core conceptsLayout
Column, Row, and Grid — the primitives that frame every routed page.
View layout →