Async includes a built-in router. Use it for URL state, link/form interception,
route params, hash routing, route partials, and route-only shells before writing
custom click, popstate, or hashchange listeners.
The router belongs to the L2 Bundle capability set, but it can still be loaded
directly from the browser router subpath. It is not a file-system router and it
does not fetch hidden pages. You register route patterns in JavaScript, choose a
navigation mode, and decide whether a route renders a partial into an
async:boundary or only updates router.* signals.
The router coordinates five pieces:
| Piece | Purpose |
|---|---|
| route declarations | Map URL patterns such as /products/:id to route records |
| partial declarations | Render route records into HTML fragments when a mode uses partials |
| boundary | Receives rendered route HTML, usually async:boundary="route" |
| history mode | Writes path URLs or hash URLs and handles back/forward navigation |
| router signals | Publishes router.path, params, query, route metadata, pending state, and errors |
If an app already has its own view renderer, use mode: "signals" and bind the
view boundary to router.* state instead of using route partials.
Most apps should stay at the app registration layer: declare route patterns and
partials with Async.use(...), then start the app. The router is materialized
from the same registry path as signals, handlers, components, cache entries,
and server calls.
Async.use({
partial: {
home() {
return html`<h1>Home</h1>`;
},
"product.page"({ id }) {
return html`<h1>Product ${id}</h1>`;
}
},
route: {
"/": defineRoute("home"),
"/products/:id": defineRoute("product.page"),
"*": defineRoute("notFound.page")
}
});
Async.start({
mode: "csr",
boundary: "route",
root: document
});Use the next layer down only when the app needs a more specific routing shape:
| If the app is doing this | Use this pattern |
|---|---|
| Client-rendered route pages with a route boundary | Async.use({ route, partial }) and Async.start({ mode: "csr", boundary: "route" }) |
| Static hosting where every URL must load one HTML file | Add urlMode: "hash" and link to #/path routes |
| SSR or static HTML should stay visible until later navigation | Use mode: "spa" with the same route and partial declarations |
| Buttons, handlers, redirects, or preloads need programmatic routing | Call Async.router.navigate(...) or Async.router.prefetch(...) |
| Dashboards or shells render from URL state instead of route partials | Use mode: "signals" and bind views with Async.router.loader.swap(...) |
| Several route-driven boundaries refresh at different rates | Register refresh scopes with Async.router.loader.defineRefreshPlan(...) |
| Code needs the router object itself, not just navigation | Await Async.router.ready() |
| Navigation belongs to the server or separate documents | Use mode: "ssr" or mode: "mpa" and let browser navigation stay server-led and native |
| A custom runtime already owns materialized registries and a loader | Use createRouter(...) directly |
CSR mode starts from an empty route boundary. On startup it renders the current route partial, then intercepts same-origin links and GET forms for later navigation.
<main async:container>
<nav>
<a href="/">Home</a>
<a href="/products/sku-1">Product</a>
</nav>
<section async:boundary="route"></section>
</main>import {
Async,
defineRoute,
html
} from "@async/framework";
Async.use({
partial: {
home() {
return html`<h1>Home</h1>`;
},
"product.page"({ id }) {
return html`
<article>
<h1>Product ${id}</h1>
<a href="/">Back home</a>
</article>
`;
}
},
route: {
"/": defineRoute("home"),
"/products/:id": defineRoute("product.page"),
"*": defineRoute("notFound.page")
}
});
Async.start({
mode: "csr",
root: document,
boundary: "route"
});route(...) remains a compatibility alias for defineRoute(...).
Use Async.use({ route, partial }) for app registration, then call
Async.router when code needs imperative navigation. This keeps route and
partial declarations in the same registry path as signals, handlers,
components, cache entries, and server calls. Async.router.navigate(...) and
Async.router.prefetch(...) queue until the runtime router exists.
Async.router.loader.* gives the same queued access to the active router
loader's swap, refresh, scan, and attach APIs.
Async.start({
mode: "csr",
root: document.body,
boundary: "route"
});
await Async.router.navigate("/products/sku-1");Use Async.router.ready() only when code needs the router object itself:
const router = await Async.router.ready();
router.signals.subscribe("router.path", syncPath);createRouter(...) is the lowest-level layer. It is reserved for custom
runtime integration that already has materialized runtime registries. It starts
immediately when called and rejects a separate signals option so router state
always belongs to the runtime loader's signal registry or to the router's own
standalone loader.
Routes are matched against normalized URL paths.
| Pattern | Matches | Params |
|---|---|---|
/ |
/ |
{} |
/products |
/products |
{} |
/products/:id |
/products/sku-1 |
{ id: "sku-1" } |
/docs/:section/:page |
/docs/runtime/router |
{ section: "runtime", page: "router" } |
/repo/tree/*rest |
/repo/tree/feature/deep/path |
{ rest: "feature/deep/path" } |
* |
any unmatched path | {} |
Specific routes rank ahead of dynamic routes, splat routes rank below
single-segment params, and wildcard routes rank last. A *name splat segment
must be the last segment of its pattern and captures the remaining path
segments (decoded per segment) as one param. Malformed encoded params are
preserved instead of crashing navigation.
Query strings are available through router.query:
/products/sku-1?tab=reviews
router.path -> "/products/sku-1"
router.params -> { id: "sku-1" }
router.query -> { tab: "reviews" }String definitions point to partial IDs:
defineRoute("product.page")Object definitions can carry metadata and route-only intent:
defineRoute({
render: "none",
meta: {
page: "dashboard",
nav: "reports"
}
})The matched definition is published at router.route, so handlers and signals
can branch on route metadata without reparsing the URL.
Matched routes can also force native document navigation — useful for downloads or raw endpoints that live under an otherwise-routed URL space:
defineRoute({ render: "document" })Server-rendered apps keep their HTML on the server. Mark routes with
server: true and client navigation fetches the fragment instead of rendering
a local partial:
Async.use({
route: {
"/": defineRoute({ server: true }),
"/:org/:name/tree/*rest": defineRoute({ server: true })
}
});
Async.start({
mode: "spa",
boundary: "page",
fallback: "document",
root: document
});Navigation to a server route fetches the target URL with
Accept: application/x-async-partial and an x-async-boundary request header
naming the boundary the transition plan wants filled. The server responds with
a wire server envelope:
{
"__async_server_result__": 1,
"title": "History · async/framework",
"html": "<main>…</main>",
"signals": { "commit.count": 3 }
}The envelope flows through the same pipeline as local partial output: signals
and browser cache patches apply first, html swaps into the plan boundary (or
the envelope's own boundary override), redirect follows the router redirect
path, and a string title updates document.title. HTTP redirects followed by
the fetch update router state and browser history to the final URL.
With fallback: "document", a response that is not an envelope (or a failed
fetch) falls back to native document navigation, so a server that does not
recognize the Accept header degrades to a normal page load.
createRouter({ fetch }) overrides the window fetch for tests or custom
transports; the router never reaches for ambient globalThis.fetch.
fallback: "document" makes unmatched navigation leave the app natively
instead of recording router.error. Register the views you want client-side;
every other same-origin link keeps working as a normal document load. The
router refuses to document-assign the current browser URL, so an unmatched
startup URL cannot reload-loop.
List/detail views keyed by a query param (a commit picker, an inbox, a file
list) want two navigation grains: changing the view re-renders everything,
changing the selection should only re-render the detail region. Declare a
viewKey function for view identity and a subBoundary for the detail
region:
Async.use({
route: {
"/:org/:name/commits/*ref": defineRoute({
server: true,
viewKey: ({ params }) => `commits:${params.org}/${params.name}/${params.ref}`,
subBoundary: "history-detail"
})
}
});<section async:boundary="page">
<div class="rail">…commit list…</div>
<div async:boundary="history-detail">…selected commit…</div>
</section>Selecting a commit navigates to ?commit=<sha> on the same view: the router
fetches the same URL with x-async-boundary: history-detail, the server
returns only the detail fragment (optionally with boundary set in the
envelope), and the rail — including its scroll position — stays attached.
Switching ref changes the computed viewKey, so the full page boundary
re-renders. Back/forward navigation replays the same plans.
| Mode | Initial route | Later navigation | Use when |
|---|---|---|---|
csr |
Client renders a local partial into an empty boundary | Client renders and swaps | A no-build page owns route content on the client |
spa |
Existing route HTML may already be present | Client renders and swaps | SSR or static HTML should stay visible until navigation |
signals |
Existing HTML stays attached | Router updates signals and history only | A shell renderer reacts to URL state itself |
ssr |
Server-rendered document activates | Browser navigation stays native | Navigation belongs to the server |
mpa |
Any document source | Browser navigation stays native | Traditional multi-page navigation |
csr, spa, and signals intercept same-origin links, GET forms, back/forward
events, and hash route changes. ssr and mpa leave browser navigation
server-led and native.
Native non-GET forms are the L0 server-led action path. A form with ordinary
method and action attributes can post to any backend language, and the
server can return the next full document or an explicitly handled fragment
response. The router does not hide those POST/action verbs behind implicit
client interception; partial POST flows should use an explicit server envelope,
command handler, or app transport.
Use mode: "signals" when route changes should update state without rendering
partials or swapping the route boundary. This is useful for dashboards and
application shells that already derive the visible view from state.
Async.use({
route: {
"/pbi": defineRoute({ render: "none", meta: { page: "pbi" } }),
"/fy26": defineRoute({ render: "none", meta: { page: "fy26" } })
}
});
Async.start({
mode: "signals",
urlMode: "hash",
root: document.body
});
await Async.router.loader.swap({
type: "bind",
boundary: "app-shell",
render({ signals }) {
const rendered = renderShell(signals.get("router.route"));
return rendered.html ?? rendered;
},
strategy: "morph"
});In signals mode, partials.render(...) is not called. Missing routes update
router.error and leave the DOM unchanged.
For high-frequency dashboard updates, bind or swap smaller nested boundaries for filters, timelines, details, and modals. Reserve a full shell swap for rare chrome-level changes.
Hash SPAs with nested async:boundary regions can register refresh scopes once
and batch same-tick updates:
await Async.router.loader.defineRefreshPlan({
chrome: {
boundaries: ["app-chrome", "view-filters"],
render({ signals }) {
return {
"app-chrome": { html: renderChrome(signals), strategy: "morph" },
"view-filters": { html: renderFilters(signals), strategy: "morph" }
};
}
},
timeline: {
boundaries: ["view-timeline"],
render({ signals }) {
return {
"view-timeline": {
html: renderTimeline(signals),
strategy: "morph",
attach: "rebind"
}
};
}
},
content: {
boundaries: ["view-detail", "view-page"],
render({ signals }) {
return {
"view-detail": renderDetail(signals),
"view-page": renderPage(signals)
};
}
}
});
await Async.router.loader.refresh("timeline");
await Async.router.loader.refresh("content");Use loader.swap({ type: "many", ifChanged: true, scan: "once", updates })
when a single event should refresh several boundaries but skip unchanged HTML
snapshots. Use loader.swap({ type: "bind", deps: [...] }) when only specific
signal paths should trigger a bound region refresh.
Static hosts can use hash routes so every route loads through one index.html.
Async.start({
mode: "csr",
urlMode: "hash",
boundary: "route",
root: document
});With urlMode: "hash", #/docs/getting-started is matched as
/docs/getting-started. Plain section anchors such as #quickstart remain
native page jumps and do not mutate router state.
<a href="#/products/sku-1">Product route</a>
<a href="#quickstart">Section anchor</a>Client navigation modes intercept:
- same-origin
<a href="...">clicks; - same-origin GET form submissions;
popstatefrom browser back/forward;- route hashes such as
#/products/sku-1whenurlMode: "hash"is enabled.
The router does not intercept:
- external links;
- links with
targetother than the current browsing context; - downloads;
- modified clicks such as command-click or control-click;
- non-GET forms, which remain native server-led actions unless the app wires an explicit command or transport;
- plain section hashes in hash mode.
Use Async.router.navigate(url) for programmatic navigation:
await Async.router.navigate("/products/sku-1");
await Async.router.navigate("/products/sku-2", { replace: true });
await Async.router.navigate("/products/sku-3", { history: false });Router state lives under router.* signals:
| Signal | Value |
|---|---|
router.url |
Full normalized route URL |
router.path |
Matched route pathname |
router.params |
Dynamic params from the route pattern |
router.query |
Query object derived from the URL search params |
router.route |
Matched route definition or null |
router.pending |
true while a partial-rendering navigation is in flight |
router.error |
Last navigation error or null |
Example DOM bindings:
<span signal:text="router.path"></span>
<section class:loading="router.pending"></section>Example handler:
Async.use({
handler: {
"nav.next"({ router }) {
return router.navigate("/products/sku-2");
}
}
});Route partials can return strings, html templates, DOM fragments, or response
envelopes.
Async.use({
partial: {
"product.page": async function ({ id }) {
const product = await this.server.products.get(id);
return {
html: html`<h1>${product.title}</h1>`,
signals: {
"product.current": product
},
cache: {
browser: {
[`product:${id}`]: product
}
}
};
}
}
});For route partial envelopes:
status: 204, a missinghtmlkey, and barenullorundefinedpartial results mean no route HTML replacement.html: undefinedalso skips replacement and emits a dev warning.html: ""intentionally clears the route boundary.redirectfollows the router redirect path.signalsand browser cache patches apply before the route boundary swap.
Async.router.prefetch(url) renders a local partial and returns its result without
mutating router state, browser history, or the DOM.
const preview = await Async.router.prefetch("/products/sku-1");Prefetch can still execute partial code. Keep partial prefetch work idempotent or move side effects behind explicit user actions.
For server: true routes, prefetch fetches the envelope and caches it briefly
(prefetchTtlMs, default 5000ms). The next navigation to the same URL and
boundary consumes the cached envelope instead of refetching — prefetch on
hover, navigate on click, pay for one request. Entries are single use and
expire silently; a stale or boundary-mismatched entry falls back to a normal
navigation fetch.
| Symptom | Check |
|---|---|
| Links reload the whole page | Confirm the router mode is csr, spa, or signals, and that links are same-origin without target or download. |
| The first route does not render | Confirm the route boundary exists and boundary matches its async:boundary value. |
| A route matches but nothing appears | Confirm the route points to a registered partial, or use mode: "signals" for route-only state. |
| Hash routes do not match | Use #/path, not #path, for router navigation. |
| Section anchors stopped working | Plain #section anchors should stay native in hash mode; use #/section only for routes. |
| Route state updates but the DOM stays unchanged | That is expected in signals mode; render from router.* signals or switch to csr/spa. |
| A partial cleared the boundary unexpectedly | Return { status: 204 } or omit html; use html: "" only for an intentional clear. |
Before writing custom navigation code, check whether the built-in router covers the need:
- URL params, splat segments, and wildcard fallback: route patterns.
- Static-host navigation:
urlMode: "hash". - Client-rendered route content:
mode: "csr"ormode: "spa". - Server-rendered route content:
defineRoute({ server: true })overmode: "spa"withfallback: "document". - List/detail selection state in the query string:
viewKeyfunction plussubBoundary. - URL-backed dashboard state:
mode: "signals". - High-frequency state refreshes: nested boundaries with
swap(...)config types for bound, unchanged-aware, or batched updates. - Server-led navigation:
mode: "ssr"ormode: "mpa". - Route state in DOM or handlers:
router.*signals.
- Server calls: Server Calls & Cache
- Streaming boundaries: Streaming & Boundaries
- Contract: 07-routing-and-partials.md