Routing
Declarative routing with @router.* and per-route modules
Routing
Hypen routing is declarative: you describe your route table in the DSL, and the SDK wires navigation for you. The same Router { Route ... } block works across web, iOS, Android, and remote UI — the renderer never sees routing logic, just patches.
Quick Start
Declare routes in your primary template:
module App {
Router {
Route(path: "/") { HomePage() }
Route(path: "/search") { Search() }
Route(path: "/profile") { Profile() }
}
}Navigate with the reserved @router.* namespace — no action handler required:
Button { Text("Search") }
.onClick(@router.push, to: "/search")
Button { Text("Back") }
.onClick(@router.back)Each Route's body references a component (e.g. HomePage()). Register those as modules the same way you'd register any module; the SDK's auto-wire handles the rest:
import { app } from "@hypen-space/core";
import { RemoteServer } from "@hypen-space/server";
export default app
.module("HomePage")
.defineState({ posts: [] })
.onCreated(async (state) => {
state.posts = await fetchFeed();
})
.build();
// server/index.ts
new RemoteServer()
.module("App", appModule)
.source("./components")
.listen(3000);No routing glue in host code. The server discovers every Router { Route ... } block at session start, matches each route body against registered modules, and spins up a per-session ManagedRouter that mounts / unmounts modules as the URL changes.
The @router.* namespace
Four reserved actions — dispatch them from any DSL click, no user handler needed:
| Action | Effect |
|---|---|
@router.push | Push a new path onto the history stack. Payload: { to: "/x" }. |
@router.replace | Replace the current path in place. Payload: { to: "/x" }. |
@router.back | Pop the current path off history. |
@router.forward | Server-side no-op. Reserved for client-side history forward. |
Row {
Button { Text("Home") }.onClick(@router.push, to: "/")
Button { Text("Search") }.onClick(@router.push, to: "/search")
Button { Text("←") }.onClick(@router.back)
}The server's primary state mirrors the current path into state.location automatically (when the primary's state shape has a location field). You can bind against it for active-tab styling:
Button { Text("Search") }
.onClick(@router.push, to: "/search")
.color("@{state.location == '/search' ? '#3B82F6' : '#6B7280'}")Route parameters
Use :param in the path pattern:
Router {
Route(path: "/users/:id") { UserProfile() }
Route(path: "/posts/:postId/comments/:cid") { CommentView() }
}Read params inside a per-route module's lifecycle. onActivated fires on every navigation (fresh mount or persist-cache restore), so it's the canonical place to load route-param-dependent data:
import { app } from "@hypen-space/core";
export default app
.module("UserProfile")
.defineState<{ user: User | null }>({ user: null })
.onActivated(async (state, ctx) => {
const match = ctx?.router?.matchPath("/users/:id", ctx.router.getCurrentPath());
const id = match?.params.id;
if (id) state.user = await fetchUser(id);
})
.build();Every SDK exposes the same pattern. Kotlin: ctx?.getRouter()?.matchPath(...). Swift: ctx?.router?.matchPath(...). Go: ctx.Context.GetRouter().MatchPath(...).
Rust exception. The Rust SDK's on_activated is read-only. Use session.on_route_enter(pattern, |params, state, ctx| …) instead — it fires from the same @router.* dispatch path and gives you mutable access to every scope's state:
session.on_route_enter("/users/:id", move |params, state, _ctx| {
let id = params.get("id").cloned().unwrap_or_default();
if let Some(slot) = state.get_mut("userprofile") {
if let Some(obj) = slot.as_object_mut() {
obj.insert("user".into(), json!(load_user(&id)));
}
}
});Per-route modules
Every Route(path:) { Foo() } can be backed by a real module — state, actions, lifecycle. The SDK auto-mounts it when the route becomes active and unmounts when you navigate away:
app.module("Comments")
.defineState<{ postId: string; comments: Comment[]; commentText: string }>({
postId: "",
comments: [],
commentText: "",
})
.onActivated(async (state, ctx) => {
const m = ctx?.router?.matchPath("/comments/:postId", ctx.router.getCurrentPath());
state.postId = m?.params.postId ?? "";
state.comments = await fetchComments(state.postId);
})
.onAction("postComment", async ({ state }) => {
await submitComment(state.postId, state.commentText);
state.commentText = "";
})
.build();Inside Comments/component.hypen, @{state.comments} resolves against the Comments module's scope:
module Comments {
ForEach(items: @state.comments, key: "id") {
Text("@{item.user.username}: @{item.text}")
}
Input(placeholder: "Add a comment...").bind(@state.commentText)
Button { Text("Post") }.onClick(@actions.postComment)
}Persistence (no loading flash on back-nav)
Module-backed routes persist by default. Navigate away and back and you get the same instance — no onCreated re-run, no loading flash.
first visit: construct → onCreated → onActivated
navigate away: onDeactivated (cached)
revisit: onActivated onlyOpt out per module:
app.module("Ephemeral")
.defineState({ ... }, { persist: false })
.build();The persist cache is a bounded LRU (default cap 10 modules) so long sessions can't leak. Evicted modules fire onDestroyed and are torn down cleanly.
Engine-level subtree cache
Underneath the SDK, the engine's Router IR keeps a per-route UI subtree cache keyed by pattern. The leaving route's subtree is detached (engine emits Detach patches, not Remove) and reattached on return (Attach, not Create). DOM scroll, form values, and focus all survive on web for free. Cap: 10 routes, same LRU semantics.
Route-param changes that hit the same pattern (/users/42 → /users/99) don't invalidate the cache — only the reactive bindings re-evaluate. Matches React Router / Vue Router semantics.
Nested routers
A per-route module's own template can declare its own Router { Route ... } block. The SDK's auto-wire discovers nested Routers from every component template (not just the primary) and flattens them into a single route table against one HypenRouter. All routes share the URL space:
// App/component.hypen
module App {
Router {
Route(path: "/") { Home() }
}
}
// Home/component.hypen — nested Router
module Home {
Column {
Header()
Router {
Route(path: "/home/feed") { Feed() }
Route(path: "/home/explore") { Explore() }
}
}
}Navigating to /home/feed mounts Feed inside Home's subtree. Authors spell out the full prefix in each Route(path:) — there's no automatic sub-path stitching.
Path conflicts: the outermost Router wins. discoverRouters emits outer blocks first, so if both the primary and a nested module declare Route(path: "/"), the primary's route is kept and the nested duplicate is ignored (debug-logged).
Server setup
// TypeScript — @hypen-space/server
new RemoteServer()
.module("App", appModule) // primary
.source("./components") // discovers App, Home, Search, ...
.listen(3000);// Go — github.com/hypen-space/core/remote
remote.NewRemoteServer().
WithDefinition(appDef).
Source("./components").
UI(appTemplate).
ListenAndServe(":3000")// Swift — HypenServer
try RemoteServer()
.module("App", appModule)
.ui(appTemplate)
.componentsDir("./components")
.listenAndWait(3000)// Kotlin — space.hypen.core
HypenServer {
module("App", appModule)
route("/", "App")
watchComponents("./components")
}// Rust — hypen-server
// Rust SDK uses explicit route registration via on_route_enter
// and module factories. See the Rust servers guide for setup.Opt out of auto-wire with .disableAutoRouter() (TS/Go/Swift) or .disableAutoRouter() on the Kotlin builder, then wire a ManagedRouter by hand in an onSessionCreate hook.
How it works
Route matching is an engine concern — not a renderer one. Router and Route are first-class IR nodes:
- The engine evaluates
state.locationagainst everyRoute(path: ...)pattern viaportable::route::match_path. Exact segments,:paramcaptures, and trailing/*wildcards are supported. - Location changes emit minimal patches:
Detachthe leaving subtree,Attachthe cached one, orCreate+Insertfresh. - The renderer (DOM, Canvas, SwiftUI, Compose) applies patches. No platform carries routing logic.
Same contract everywhere:
- Web: browser
history.pushState/ hash navigation - iOS: SwiftUI view-tree swaps driven by
state.location - Android: Compose recomposition driven by
state.location - Remote UI: server-side state streamed over WebSocket
Next steps
- State & Modules — module lifecycle, action handlers
- Control Flow — ForEach, When, If
- Server guides: TypeScript, Go, Swift, Kotlin, Rust