Routing.
A tiny, hash-based router lets an LLM-authored UI span multiple
pages. Navigation stays in sync with the URL
(#/dashboard, #/users/42), browser
back/forward works, and deep links survive a refresh — all without
react-router or any framework-specific dependency.
Routes(items, default?)
Outlet that renders the matching Route child based on
the current hash path. First match wins; falls back to
default when nothing matches.
Route(path, content)
Declares one page. path supports literal segments,
:param placeholders, and a trailing
* wildcard.
NavLink(label, to, …)
Anchor that navigates without reloading the page and automatically
reflects data-active="true" for the current path.
@Navigate("/path")
Action step for programmatic navigation. Use inside any
Action([...]) chain to move after a save, submit,
or follow-up.
Routing is always on
Every <streaming-ui-script> renderer starts the
built-in router automatically. The default
getSystemPrompt() output ("full" mode) includes the
routing section so the LLM can confidently emit
Routes / NavLink / @Navigate.
Pick the lighter { mode: "chat" } prompt only when you
want to skip routing-aware UIs for chat-style replies.
1. Drop in the renderer
No flags needed — the router is part of the runtime. Calling
getSystemPrompt() on the element teaches the model how to
wire Routes / NavLink / @Navigate.
<streaming-ui-script
id="renderer"
theme="light"
></streaming-ui-script>
<script type="module">
import "https://asfand-dev.github.io/streaming-ui-script/dist/streaming-ui-script.js";
const el = document.getElementById("renderer");
const systemPrompt = el.getSystemPrompt({
preamble: "You are a UI assistant building a multi-page dashboard.",
});
</script>
Heads up: proxy elements
If you build the system prompt from a temporary<streaming-ui-script> element (a common pattern when
each turn renders into its own instance), just call
getSystemPrompt() — routing is part of the default
("full") prompt:
function buildPrompt() {
const proxy = document.createElement("streaming-ui-script");
return proxy.getSystemPrompt(); // includes routing by default
}
const renderer = document.createElement("streaming-ui-script");
2. Anatomy of a multi-page program
The canonical layout: a persistent navigation built from
NavLinks, followed by a Routes(...) outlet
that swaps in the matching page. The root only references
named pages — every page becomes its own line that streams in
independently.
root = Stack([nav, main])
nav = Stack([
NavLink("Home", "/", "ghost", true),
NavLink("Dashboard", "/dashboard", "ghost"),
NavLink("Users", "/users", "ghost"),
NavLink("Settings", "/settings", "ghost")
], "row", "s")
main = Routes([
Route("/", homePage),
Route("/dashboard", dashboardPage),
Route("/users", usersPage),
Route("/users/:id", userDetailPage),
Route("/settings/*", settingsArea),
Route("*", notFoundPage)
], "/")
homePage = Card([CardHeader("Welcome")])
dashboardPage = Card([CardHeader("Dashboard")])
usersPage = Card([CardHeader("Users")])
userDetailPage = Card([CardHeader("User " + params.id)])
settingsArea = Card([CardHeader("Settings"), TextContent("Section: " + params._)])
notFoundPage = Callout("warning", "Not found", "We couldn't find " + $route + ".")
3. Path patterns
Route patterns are matched left-to-right, one segment at a
time. They are designed to be cheap to evaluate (no regex compilation,
no allocator pressure) so swapping pages is essentially free.
| Pattern | Matches | Captures |
|---|---|---|
"/" |
Only the root path. | — |
"/about" |
Exact path #/about. |
— |
"/users/:id" |
#/users/42, #/users/jane. |
params.id |
"/teams/:teamId/members/:memberId" |
Nested parameters. | params.teamId, params.memberId |
"/docs/*" |
#/docs, #/docs/guides/intro. |
params._ = remainder |
"*" |
Anything (use as the final route for 404 / fallback). | params._ |
4. Reactive surfaces
The router exposes two reactive surfaces that any expression can read:
| Surface | Type | Where it's available |
|---|---|---|
$route |
string |
Anywhere. Holds the current path (e.g. "/users/42").
The runtime owns it — never declare $route = "..."
yourself.
|
params |
object |
Inside the matched Route's content only. Acts like
an @Each loop variable: params.id,
params._ (wildcard remainder), etc. Undefined
outside that scope.
|
5. Programmatic navigation
Use the @Navigate action step inside any
Action([...]) chain to push a new path. Combine it with
@Run, @Set, or @ToAssistant
for hybrid flows (save → navigate → notify):
saveBtn = Button("Save", Action([
@Run(saveMutation),
@Navigate("/dashboard"),
@ToAssistant("Profile updated.")
]))
cancelBtn = Button("Cancel", Action([@Navigate("/dashboard")]), "ghost")
From plain JavaScript (e.g. integration tests, an outer chat UI, or a
custom hotkey handler), call el.navigate("/path") on the
element:
const renderer = document.querySelector("streaming-ui-script");
renderer.navigate("/users/42");
// Listen for hash changes (yours or the user's):
renderer.addEventListener("route-change", (event) => {
console.log("now at", event.detail.path, event.detail.params);
});
6. NavLink behaviour
-
Renders as an
<a>withhref="#/your-path", so middle-click / open-in-new-tab still work like a regular link. -
Click is intercepted and translated to an in-memory
@Navigateso the page never reloads. -
Reflects
data-active="true"when the current path is equal totoor starts withto + "/"— perfect for highlighting a parent nav entry while a child route is active. -
Pass
exact=trueto require strict equality. Always do this for a home link"/", otherwise every other path will also light it up. -
Variants:
"default"(block link),"primary","ghost"(great for nav rows), and"pill"(compact pill shape). Pass an emoji asiconfor a leading glyph.
7. Worked example: deep-linkable user detail
A two-page mini app with a list view and a detail view. The detail
page reads params.id straight from the URL, so a
bookmarked #/users/42 renders exactly the same content
whether the user arrived from the list, a typed URL, or the browser
history.
$users = [
{id: "ada", name: "Ada Lovelace", role: "Founding engineer"},
{id: "grace", name: "Grace Hopper", role: "Compiler researcher"},
{id: "lin", name: "Lin-Manuel", role: "Product designer"}
]
root = Stack([nav, main])
nav = Stack([
NavLink("Home", "/", "ghost", true),
NavLink("Users", "/users", "ghost")
], "row", "s")
main = Routes([
Route("/", home),
Route("/users", userList),
Route("/users/:id", userDetail),
Route("*", notFound)
], "/")
home = Card([
CardHeader("Welcome", "Pick a section from the nav"),
Buttons([Button("Browse users", Action([@Navigate("/users")]), "primary")])
])
userList = Card([
CardHeader("Users", "Click a row to deep-link"),
Table([
Col("Name", $users.name),
Col("Role", $users.role),
Col("", @Each($users, "u", openRow))
])
])
openRow = Button("Open", Action([@Js("ctx.host.navigate('/users/' + ctx.args.id)", {id: u.id})]), "ghost")
userDetail = Card([
CardHeader("User " + params.id, "Detail view"),
TextContent("Role: " + (($users.find ? $users.find(u => u.id === params.id) : null)?.role ?? "—")),
Buttons([
Button("Back", Action([@Navigate("/users")]), "ghost")
])
])
notFound = Callout("warning", "Not found", "No page matches " + $route)
8. Worked example: settings with nested area
Combine a literal route with a trailing wildcard to model a nested
section. The wildcard remainder lands in params._, so
you can drive a secondary nav from the URL itself without redeclaring
the route map.
root = Stack([nav, main])
nav = Stack([
NavLink("Home", "/", "ghost", true),
NavLink("Settings", "/settings", "ghost")
], "row", "s")
main = Routes([
Route("/", home),
Route("/settings", settingsHome),
Route("/settings/*", settingsArea),
Route("*", notFound)
], "/")
home = Card([CardHeader("Welcome", "Open Settings, then click a sub-section")])
settingsHome = Card([
CardHeader("Settings"),
Stack([
NavLink("Profile", "/settings/profile", "pill"),
NavLink("Notifications", "/settings/notifications", "pill"),
NavLink("Security", "/settings/security", "pill")
], "row", "s")
])
settingsArea = Card([
CardHeader("Settings · " + params._, "Wildcard route"),
TextContent("Sub-section: " + params._),
Buttons([Button("Back", Action([@Navigate("/settings")]), "ghost")])
])
notFound = Callout("warning", "Not found", "No page matches " + $route)
9. Common mistakes
-
Declaring
$routeyourself. The runtime owns the variable; any assignment is overwritten on the nexthashchange. Read it freely, but never write it. -
Putting
Routesinside a conditional that hides the nav. Render the nav once at the top ofrootso it stays visible across every page. -
Forgetting the wildcard route. Without
Route("*", …)(or thedefaultargument), an unknown URL renders an empty outlet. -
Using a regular
Link(...)withhref="#/path". That works, but it doesn't highlight the active section. PreferNavLinkfor in-app navigation; keepLinkfor external URLs. -
Reading
paramsoutside the matched Route.paramsis scoped to the active Route's content — outside that scope it's undefined.
10. JavaScript API summary
| API | Purpose |
|---|---|
el.route |
Read-only string with the current path (e.g. "/users/42"). |
el.navigate(path) |
Programmatically navigate by updating window.location.hash. |
route-change event |
CustomEvent fired on the element whenever the path changes.
event.detail contains path,
previousPath, params, and the matched
pattern.
|
Where to next?
- Live routing demo — a full multi-page app you can navigate and deep-link.
- Component reference — every component, including the routing primitives.
- JavaScript interactions — pairs well with routing for tools, modals, and persistence.