Routing
WebUI provides nested SSR routing with client-side navigation. Routes are declared as a tree in index.html, components use <outlet /> to render child routes, and the @microsoft/webui-router package handles client-side transitions after the initial SSR.
Installation
npm install @microsoft/webui-routerOnly needed when your app has client-side navigation. Server-only apps with full page loads don't need it.
Quick Start
1. Declare routes in index.html:
<body>
<route path="/" component="app-shell">
<route path="" component="home-page" exact />
<route path="users" component="user-list" exact />
<route path="users/:id" component="user-detail" exact />
</route>
<script type="module" src="/index.js"></script>
</body>2. Use <outlet /> in your shell component:
<!-- app-shell.html -->
<template shadowrootmode="open">
<nav><a href="/">Home</a> <a href="/users">Users</a></nav>
<main><outlet /></main>
</template>3. Start the router:
import { Router } from '@microsoft/webui-router';
Router.start();The server SSRs the matched route on first load. The router handles clicks on <a> tags for subsequent navigations - no full page reloads.
Nested Routes
Routes nest to any depth. Each parent component uses <outlet /> where its child route renders:
<!-- index.html -->
<route path="/" component="app-shell">
<route path="" component="dashboard" exact />
<route path="sections/:id" component="section-page">
<route path="topics/:topicId" component="topic-page">
<route path="lessons/:lessonId" component="lesson-page" exact />
</route>
</route>
</route><!-- section-page.html -->
<template shadowrootmode="open">
<h2>{{sectionName}}</h2>
<nav>topic links...</nav>
<outlet />
</template>When navigating between child routes, parent content is preserved. Navigating from /sections/1/topics/react to /sections/1/topics/css only remounts the topic component - the section heading and nav stay.
The exact Attribute
Use exact on leaf routes - routes with no children. Without exact, a route matches any URL that starts with its path, which is what you want for parent routes that have <outlet />.
<route path="/" component="app-shell">
<route path="" component="home-page" exact /> <!-- leaf: exact -->
<route path="users" component="user-list" exact /> <!-- leaf: exact -->
<route path="settings" component="settings-page"> <!-- parent: NO exact -->
<route path="profile" component="profile" exact />
<route path="billing" component="billing" exact />
</route>
</route>Rule of thumb: If a route has <outlet />, don't add exact. If it doesn't, add exact.
How It Works
First Load (SSR)
- Browser requests
/sections/1/topics/react - Server matches the full route chain:
app-shell → section-page → topic-page - Renders all matched components nested at their outlets
- Browser displays fully rendered HTML - no JavaScript needed yet
- JavaScript loads, hydration runs, router starts and reads the SSR'd active chain
Client Navigation
- User clicks a link to
/sections/1/topics/css - Router intercepts via the Navigation API
- Fetches JSON partial from server with
Accept: application/json - Server returns the matched route chain - the client does not perform route matching
- Compares old chain with new - finds first changed level
- Mounts only the changed component - parents stay mounted
- No full page reload
The Router API
Router.start(config?)
Starts the router. Call after hydration completes.
Router.start({
basePath: '/app', // optional: prefix for all route URLs
loaders: { ... }, // optional: lazy-loading map
});Router.navigate(path)
Router.navigate('/users/42');Router.back()
Router.back();Router.activeParams
The bound parameters of the current route:
console.log(Router.activeParams); // { id: "42" }Router.destroy()
Tears down the router and removes event listeners.
Router.releaseTemplates(tags?)
Release cached component templates to free memory. Removes entries from window.__webui_templates and clears their inventory bits so the server will re-send them on the next navigation that needs them.
Active route components are always skipped - you cannot release a template that is currently rendered.
// Release specific templates
Router.releaseTemplates(['user-detail', 'user-list']);
// Release all non-active templates
Router.releaseTemplates();The framework's internal template cache is a WeakMap keyed by the same meta objects, so its entries become GC-eligible automatically when the template is released.
When to use this
Most apps don't need this - the number of unique component templates is bounded by the route tree (typically 10–30). The server's inventory system already prevents duplicate downloads. Use releaseTemplates() in long-lived SPAs with many routes where memory pressure is a concern.
Lazy Loading
Lazy-load route components so their JavaScript is only fetched on navigation:
Router.start({
loaders: {
'user-list': () => import('./pages/user-list.js'),
'user-detail': () => import('./pages/user-detail.js'),
},
});- Components not in
loadersare eagerly loaded - Each loader runs at most once - cached after first call
- On SSR'd initial load, the lazy loader is skipped (content already rendered)
Navigation Events
window.addEventListener('webui:route:navigated', (event) => {
const { component, params, path } = event.detail;
});Server Contract
The server handles two request types for each route:
JSON Partial (client navigation)
When Accept: application/json:
{
"state": { "name": "Alice", "email": "alice@example.com" },
"templates": ["<f-template name=\"user-detail\">...</f-template>"],
"inventory": "04000400...",
"path": "/users/42",
"chain": [
{ "component": "app-shell", "path": "/" },
{ "component": "user-detail", "path": "users/:id", "params": { "id": "42" }, "exact": true }
]
}The chain field tells the client router which route components are active at each nesting level. The client uses this to diff against the previous chain and only remount what changed - it does not perform route matching itself.
Full HTML (initial load)
Without Accept: application/json, return the full SSR'd page. The handler automatically emits a <meta name="webui-inventory"> tag in <head> so the client router knows which templates are already loaded.
Building the chain
Use render_partial() (Rust) or webui_render_partial() (FFI) to get the complete partial response - state, templates, inventory, path, and chain - in a single call:
// Rust
let partial = route_handler::render_partial(&protocol, state, &entry, &path, &inventory_hex);
// partial contains: { "state": {...}, "templates": [...], "inventory": "...", "path": "...", "chain": [...] }// C#
string partialJson = handler.RenderPartial(protocol, stateJson, entryId, requestPath, inventoryHex);// Node.js
const partialJson = webui.renderPartial(protocol, stateJson, entryId, requestPath, inventoryHex);Express Example
app.get('/users/:id', (req, res) => {
const state = { name: getUser(req.params.id).name };
if (req.accepts('json')) {
// renderPartial() returns the complete response - no assembly required
const stateJson = JSON.stringify(state);
res.type('json').send(webui.renderPartial(protocol, stateJson, 'index.html', req.path, req.get('X-WebUI-Inventory') ?? ''));
} else {
res.type('html').send(handler.render(protocol, state, 'index.html', req.path));
}
});Security
Route parameters (:id, :name, etc.) are extracted from URLs and injected into component state. They are automatically HTML-escaped when rendered with double braces ({{param}}), but not when rendered with triple braces ({{{param}}}).
⚠️ Never use triple braces (
{{{...}}}) to render route parameters. An attacker could craft a URL like/users/<script>alert(1)</script>to inject arbitrary HTML.
Always validate route parameters on the server before including them in state.
Route-Scoped State
For optimal performance, each route handler should return only the state that its component template binds to - not the full application state.
Anti-pattern: Full State for Every Route
// ❌ Returns everything for every route - 240 KB per navigation
{
"folders": [...],
"threads": [...],
"messages": [...],
"settings": {...},
"contacts": [...]
}Correct: Route-Scoped State
// ✅ /inbox - only what the inbox component needs - 15 KB
{ "threads": [...], "selectedFolder": "inbox" }
// ✅ /inbox/:id - only what the detail component needs - 5 KB
{ "subject": "Q4 Review", "messages": [...] }
// ✅ /settings - only settings data - 2 KB
{ "theme": "dark", "language": "en", "notifications": true }Route-scoped state keeps JSON payloads small during client-side navigation, where only the state field of the JSON partial is transferred.
Styling Route Outlets
<webui-route> elements rendered by <outlet /> are bare custom elements with display: inline by default. If the outlet's parent uses flexbox or grid layout, you need to style the route element:
/* In the parent component's CSS */
.content-area > webui-route {
display: flex;
flex-direction: column;
flex: 1;
}Hidden routes use style="display:none" inline. If your CSS sets display: flex, add specificity to avoid showing hidden routes:
.content-area > webui-route:not([style*="display:none"]) {
display: flex;
flex-direction: column;
flex: 1;
}Full Example
<!-- index.html -->
<body>
<route path="/" component="app-shell">
<route path="" component="home-page" exact />
<route path="contacts" component="contacts-page">
<route path="add" component="contact-form" exact />
<route path=":id" component="contact-detail" exact />
<route path=":id/edit" component="contact-form" exact />
</route>
</route>
<script type="module" src="/index.js"></script>
</body><!-- app-shell.html -->
<template shadowrootmode="open">
<header><nav-bar></nav-bar></header>
<main><outlet /></main>
</template><!-- contacts-page.html -->
<template shadowrootmode="open">
<h2>Contacts</h2>
<div class="list">...</div>
<outlet />
</template>// index.ts
import { TemplateElement } from '@microsoft/fast-html';
import { Router } from '@microsoft/webui-router';
import './app-shell.js';
TemplateElement.options({
'app-shell': { observerMap: 'all' },
}).config({
hydrationComplete() {
Router.start({
loaders: {
'home-page': () => import('./pages/home-page.js'),
'contacts-page': () => import('./pages/contacts-page.js'),
'contact-form': () => import('./pages/contact-form.js'),
'contact-detail': () => import('./pages/contact-detail.js'),
},
});
},
}).define({ name: 'f-template' });