@microsoft/webui-router
v0.0.25
Published
Lightweight client-side router for WebUI apps. Intercepts navigation, matches routes locally or via server route-data, and hydrates components.
Readme
@microsoft/webui-router
Build-time compiled router for WebUI apps. Routes, cache tags, invalidation graphs, pending states, and error boundaries are declared as HTML attributes, validated by the Rust compiler, and baked into the binary protocol — zero runtime JavaScript for routing policy.
Uses the Navigation API for client-side transitions. The server provides the matched route chain; the client does not perform route matching.
📖 Full documentation at microsoft.github.io/webui — see the Routing Guide for setup and usage.
How It Works
- Server renders the full page - the matched route chain is SSR'd with declarative shadow roots. The page is interactive before JavaScript loads.
- Hydration completes - WebUI Framework hydrates shell components.
- Router starts - reads the SSR route bootstrap data and intercepts link clicks via the Navigation API. The router never imports framework code.
- Client-side navigation - fetches a JSON partial from the server, which includes the matched route chain. The client diffs old vs new chain and mounts only the changed component. Parent components stay mounted.
Authored route components use their registered classes. Scriptless route
components also navigate without a full page reload when the application loads
@microsoft/webui-framework, which mounts the templates published by the
router.
Installation
npm install @microsoft/webui-routerQuick Start
1. Add <base href="/"> in your <head>:
<head>
<meta charset="utf-8">
<base href="/">
</head>All WebUI apps with routes must include <base href="/">. Without it, relative asset paths (CSS, JS) break on nested routes — the browser resolves app.css against /contacts/123/ → /contacts/app.css instead of /app.css.
For sub-path deployments, set the base to the sub-path: <base href="/my-app/">.
2. Declare nested 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>Child routes use relative paths (no leading /). The nesting is the route tree.
2. Use <outlet /> in parent components:
<!-- app-shell.html -->
<nav>
<a href="/">Home</a>
<a href="/users">Users</a>
</nav>
<main><outlet /></main>
<footer>© 2026</footer><outlet /> marks where child route content renders. The nav and footer persist across navigations.
The paired form <outlet></outlet> is also supported, but outlets are empty and
the self-closing form is preferred.
3. Start the router after hydration:
import '@microsoft/webui-framework';
import { Router } from '@microsoft/webui-router';
import './app-shell.js';
window.addEventListener('webui:hydration-complete', () => {
Router.start({
loaders: {
'home-page': () => import('./pages/home-page.js'),
'user-list': () => import('./pages/user-list.js'),
'user-detail': () => import('./pages/user-detail.js'),
},
});
});Components in loaders are lazy-loaded on first navigation. Components not
listed are assumed eagerly loaded when already registered. Scriptless route
templates can accept server-provided partial state through the framework
without empty .ts files. Add a same-named module only when a route owns
events, lifecycle code, decorators, or imperative APIs. If no runtime registers a destination tag, the router safely
falls back to a full document request.
Nested Routes
Routes nest to any depth. Each parent uses <outlet /> for child content:
<route path="/" component="app-shell">
<route path="" component="dashboard" exact />
<route path="settings" component="settings-page">
<route path="profile" component="profile-page" exact />
<route path="billing" component="billing-page" exact />
</route>
</route>Navigating from /settings/profile to /settings/billing only remounts the billing component — app-shell and settings-page stay mounted with their state preserved.
The exact Attribute
- Leaf routes (no children): add
exact - Parent routes (have
<outlet />): omitexact
Without exact, a route matches any URL that starts with its path — which is what parent routes need.
The keep-alive Attribute
Preserve a component across navigations instead of destroying and recreating it:
<route path="./" component="mail-view" keep-alive>
<route path="" component="inbox-page" exact />
</route>
<route path="calendar" component="calendar-page" exact keep-alive />When the user navigates away from a keep-alive route and returns, the existing component is reused. Its DOM and local state (scroll position, input values, timers) survive the round trip.
State is preserved by default. The router only updates route param and query param attributes on reactivation. This means your component's @observable properties, scroll position, form inputs, and any client-computed state all survive.
To refresh data on reactivation, define a route loader:
export class MailView extends WebUIElement {
@observable messages = [];
static async loader({ signal }: RouteLoaderContext) {
const resp = await fetch('/api/messages', { signal });
return { messages: await resp.json() };
}
}Route Loaders
Define a static loader() on a component to fetch data from a custom source instead of using server-provided state:
import type { RouteLoaderContext } from '@microsoft/webui-router';
export class LiveDashboard extends WebUIElement {
@observable source = '';
@observable metrics = {};
static async loader({ params, query, signal }: RouteLoaderContext) {
const resp = await fetch(`/api/dashboard/${params.id}`, { signal });
return { source: 'client', metrics: await resp.json() };
}
}How it works:
- The router checks each route component's constructor for a static
loader()method - Loaders run before the view transition — results are ready for synchronous DOM commit
- The loader receives route
params, parsedquery, and anAbortSignaltied to the navigation - If a loader fails, the router falls back to server-provided state with a console warning
- Loaders run on both SSR bootstrap and SPA navigations for consistency
When to use loaders:
- WebSocket-driven dashboards that manage their own data stream
- Components that fetch from a different API than the SSR server
- Keep-alive components that need fresh data on reactivation
- Any component that wants full control over its state source
Base Path (Sub-Path Deployment)
Every WebUI app with routes needs <base href="/"> in its <head> (see Quick Start above). This ensures relative asset paths resolve correctly on nested routes.
For sub-path deployments (e.g., https://example.com/commerce/), change it to the sub-path:
<head>
<base href="/commerce/">
</head>The <base> tag is a core web platform feature. It makes the browser resolve all relative URLs (<a href>, <link href>, fetch()) against the base path. The router detects it at startup and uses it to strip/prepend the prefix on navigation URLs.
When using webui serve --base-path /commerce/, the <base> tag is emitted automatically.
Preload on Hover
Opt-in speculative fetching for instant click navigation:
Router.start({ preload: true });When enabled, the router prefetches JSON partials (templates, CSS, state) when the user hovers over internal links. On click, the cached result is used immediately. Preloaded entries are stored in the tagged cache with a 5-second minimum freshness. Only mouse pointers trigger preload.
Tagged Cache
Cache partial responses with server-provided tags for precise invalidation:
Cache and preload are optional runtime tiers. The default Router.start() path
does not import the cache implementation until cache or preload opts in.
Router.start({
cache: {
staleTime: 30_000, // ms before refetch (default: 0 = disabled)
gcTime: 300_000, // ms before eviction from memory (default: 5 min)
maxEntries: 50, // LRU cap (default: 50)
},
});Declare cache tags on routes as HTML attributes:
<route path="email/:threadId" component="thread-page" exact
cache-tags="thread:{threadId},inbox" />The {threadId} placeholder is resolved at render time by the Rust handler. The server includes resolved cacheTags in the JSON partial. On revisit within staleTime, the cached response is used instantly.
Tag-Based Invalidation
Declare which tags a route invalidates after mutations:
<route path="compose" component="compose-page" exact
invalidates="inbox,sent,counts,drafts" />Programmatic invalidation:
Router.invalidateTags(['inbox', 'thread:42']); // evict by tag
Router.invalidate('/email/42'); // evict by path
Router.invalidate(); // evict everythingMutation Actions
The write counterpart to static loader(). Enable Router.start({ actions: true }) to make the router intercept <form method="post"> and auto-invalidate the cache:
import type { RouteActionContext, RouteActionResult } from '@microsoft/webui-router';
export class ComposePage extends WebUIElement {
static async action({ formData, params, signal }: RouteActionContext): Promise<RouteActionResult> {
await fetch('/api/send', { method: 'POST', body: formData, signal });
return {
invalidateTags: ['sent'], // merged with route's invalidates attr
state: { status: 'Message sent' }, // optimistic UI (optional)
};
}
}The action's returned tags are merged with the route's build-time invalidates attribute. Only same-origin forms are intercepted.
Pending UI
Show a loading component during slow navigations (>150ms):
<route path="inbox" component="inbox-page" exact pending="mail-skeleton" />The pending component is validated at build time. Keep-alive and cached routes skip pending.
Error Boundaries
Show an error component when navigation fails:
<route path="dashboard" component="dashboard-page" exact error="error-display" />The error component receives { error, status, path } as state. It can call Router.navigate() to retry.
Controlling State
| Need | Mechanism |
|------|-----------|
| Server state for authored code | Authored route component with a sibling .ts or .js module |
| Template-only soft navigation | Omit the client module and load the framework once |
| I fetch my own data | static loader() on component class |
| Preserve local state | keep-alive on route |
| Preserve DOM, refresh data | keep-alive + static loader() |
| Handle form submissions | static action() on component class |
| Cache responses | cache config on Router.start() |
| Show loading state | pending attr on route |
| Handle failures | error attr on route |
API
Router.start(config?)
Start the router. Call after hydration completes.
| Option | Type | Description |
|--------|------|-------------|
| loaders | Record<string, () => Promise<unknown>> | Lazy-loading map: component tag -> dynamic import |
| templateEndpoint | string | URL for ensureLoaded() requests (default: "/_webui/templates") |
| dev | boolean | Enable development mode warnings |
| preload | boolean | Preload routes on link hover for instant navigation |
| actions | boolean | Intercept same-origin POST forms and call component static action() handlers |
| ssrFresh | boolean | Skip initial loader replay on SSR bootstrap (default: true). Components with static ssrLoader = true still run their loader. |
| cache | CacheConfig | Tagged navigation cache: { staleTime, gcTime, maxEntries } |
Loader-owned tags are reserved before the framework claims compiler-owned
HTML-only templates. This guarantees the dynamic module retains ownership of
customElements.define() even when template metadata is already available.
Router.navigate(path)
Programmatic navigation:
Router.navigate('/users/42');Router.ensureLoaded(...tags)
Load templates and CSS for components on demand. Components must be declared as routes so the build compiles them, but they don't need to be navigated to:
<!-- Declared as a route — compiled into protocol -->
<route path="settings" component="settings-dialog" exact />// Load on demand — fetches from /_webui/templates
await Router.ensureLoaded('settings-dialog');
// Batch multiple in one request
await Router.ensureLoaded('modal-a', 'modal-b', 'drawer-c');Templates are not sent during initial SSR or partial navigation for unmatched routes — zero cost until explicitly requested. If a user navigates directly to the route path, the component renders normally in the outlet.
View Transitions
The router automatically uses the View Transitions API when available. On each client-side navigation, the DOM swap is wrapped in document.startViewTransition(), giving you a CSS-driven cross-fade between old and new route content with zero extra code.
Do NOT wrap Router.navigate() in your own startViewTransition() — the router already does this internally.
While active, the router installs a nonce-bearing
@view-transition { navigation: none; } override. This disables automatic
cross-document transitions because they conflict with intercepted routes that
must fall back to SSR document requests. Explicit client-side transitions still
use document.startViewTransition(). Router.destroy() removes the override.
To customize the animation, use view-transition-name on specific elements and target them in CSS:
/* Scope the transition to the reading pane only */
.route-outlet {
view-transition-name: reading-pane;
}
/* Custom cross-fade for the reading pane */
::view-transition-old(reading-pane) {
animation: fade-out 100ms ease-out;
}
::view-transition-new(reading-pane) {
animation: fade-in 150ms ease-in;
}The router awaits transition.updateCallbackDone (not .finished), so rapid navigations supersede each other without queuing animations.
Router.back()
Navigate back in history.
Router.invalidateTags(tags)
Evict all cache entries whose tags overlap with the given tags:
Router.invalidateTags(['inbox', 'thread:42']);Router.invalidate(path?)
Evict cache entries by path, or all entries if no path is given:
Router.invalidate('/email/42'); // one entry
Router.invalidate(); // everythingRouter.activeComponent
Component tag of the active leaf route:
console.log(Router.activeComponent); // "user-detail"Router.activeParams
Bound parameters from all nesting levels:
console.log(Router.activeParams); // { id: "42" }Router.destroy()
Tear down the router and remove event listeners.
Router.gc(tags?)
Release cached component templates to free memory. The router clears their inventory bits so the server will re-send them on the next navigation that needs them.
// Release all cached templates
Router.gc();Framework template caches become GC-eligible after the router releases the matching templates.
Navigation Events
Dispatched on window after each navigation:
window.addEventListener('webui:route:navigated', (e) => {
const { component, params, query, path } = (e as CustomEvent).detail;
});Dispatched after a mutation action completes:
window.addEventListener('webui:route:action-complete', (e) => {
const { component, invalidatedTags, path } = (e as CustomEvent).detail;
});Note: The
queryinnavigatedcontains all URL query parameters (unfiltered). Only parameters declared via thequeryattribute on<route>are set as DOM attributes — the event exposes the full set for programmatic use.
Route Path Syntax
| Pattern | Example | Matches |
|---------|---------|---------|
| literal | users | Exact segment |
| :param | users/:id | Captures segment → { id: "42" } |
| :param? | search/:query? | Optional segment |
| *splat | files/*path | Rest of path → { path: "a/b/c" } |
Paths are relative to the parent route. Use / prefix only for the root route.
Query Parameters
URL query parameters can be forwarded to components as HTML attributes by
declaring an allowlist on the <route> element:
<route path="compose" component="page-compose" query="action,to,subject" exact />When a user navigates to /compose?action=reply&[email protected], only the
three listed parameters are set as attributes on <page-compose>. Any
unlisted parameter (e.g. ?class=evil&style=display:none) is silently
dropped — deny-by-default.
Rules
| Scenario | Behavior |
|----------|----------|
| No query attribute | No query params forwarded (safe default) |
| query="action,to" | Only action and to are set as attributes |
| Collision with route param | Route param wins — query param is skipped |
| Query-only navigation | Stale attributes from previous query are removed |
Component usage
Declare @attr properties matching the allowed query param names:
export class PageCompose extends WebUIElement {
@attr action = '';
@attr to = '';
@attr subject = '';
}Server Contract
On client-side navigation, the router sends:
GET /users/42
Accept: application/x-ndjson, application/json
X-WebUI-Inventory: <hex bitmask>The server should return:
Accept: application/x-ndjson→ NDJSON streaming: Chunk 1{ templateStyles, templates, inventory, path, chain, cacheTags }, Chunk 2{ states: [...] }— or fall back to single JSONAccept: application/json→ JSON partial:{ state, templateStyles, templates, inventory, path, chain, cacheTags, cacheControl };Protocol::render_partial()returns this complete response- Otherwise → Full SSR'd HTML page
The chain field contains the matched route chain with component, path,
params, exact, keepAlive, pendingComponent, errorComponent, and
invalidates. The cacheTags array contains resolved cache tags from the full
chain. The optional cacheControl object can override staleTime per-response.
See the Routing guide for complete server implementation examples.
Architecture
The router is organized into 13 internal modules, each handling a single concern:
| Module | Responsibility |
|--------|---------------|
| router | Core router lifecycle, Navigation API integration |
| chain | SSR route bootstrap and active route binding |
| navigation-path | Path matching and parameter extraction |
| route-element | <webui-route> custom element and query param handling |
| loaders | Static loader() resolution with ssrFresh support |
| actions | Optional form submission interception and static action() dispatch |
| cache | Tagged LRU navigation cache |
| pending | Lazy pending/error UI lifecycle |
| preload | Optional hover-based speculative prefetching |
| templates | Template injection and inventory management |
| streaming | NDJSON streaming partial responses |
| browser-shim | Navigation API type shims |
| types | Public type definitions and type guards |
SSR bootstrap data
On first load, the server emits inert route bootstrap data in #webui-data. The
router reads it during startup:
<script type="application/json" id="webui-data">
{
"chain": [],
"inventory": "04000400...",
"nonce": "abc123",
"css": ["/styles/main.css"],
"styles": ["app-shell"],
"state": {}
}
</script>The router reads this at startup, eliminating DOM walking and URLPattern usage.
Exports
The package exports the following:
| Export | Kind | Description |
|--------|------|-------------|
| Router | class | Main router singleton |
| WebUIRouter | class | Same as Router (named export) |
| WebUIRouteElement | class | <webui-route> custom element |
| parseQuery | function | Parse URL query string into a record |
| filterQuery | function | Filter query params by an allowlist |
| isStateful | function | Type guard - checks if an element accepts route state |
| StatefulElement | type | Interface for elements that accept route state |
| RouterConfig | type | Configuration for Router.start() |
| RouteLoaderContext | type | Context passed to static loader() methods |
| RouteActionContext | type | Context passed to static action() methods |
| RouteActionResult | type | Return type of static action() |
| CacheConfig | type | Cache configuration options |
| NavigationEvent | type | Detail type for webui:route:navigated events |
| ActionCompleteEvent | type | Detail type for webui:route:action-complete events |
License
MIT
