@weftui/router API Reference
Universal nested router for Weft. See the Routing guide for a narrative walkthrough.
Three entry points mirror @weftui/dom:
| Import | Use |
|---|---|
@weftui/router |
Shared authoring + universal nodes (Router, href, RouterApp, errors). |
@weftui/router/client |
Client runtime (RouterLive, programmatic navigate/push/…, re-exported nodes). |
@weftui/router/server |
Server rendering (RouterServer). |
Router
Router is both an Effect Context.Service key and the authoring namespace; the two roles merge by declaration. yield* Router reads the per-render service; Router.route(…) authors a tree.
The service value carries:
currentMatch: the current match as a hotSubscribable<RouteMatch>; drives the outlet.navigate(to, options?): navigates to a path (with optional query).options.replaceswapspushStateforreplaceState. On the client this updates History state and re-renders the affected outlet; on the server it is a no-op.httpApiClient: the derivedRouterHttpApiClientas anOption. It isOption.someon the client (RouterLive) for network work (route prefetch, future loaders),Option.noneon the server (it is itself the origin). SPA URL→leaf resolution does not use this; it stays local via the shared matcher.
Router.route
Router.route<Path, Query, S>(
segment: string,
config: { path?: Path; query?: Query; component: S },
): RouteNode<Path, Query, E, R>;Declares a leaf page. segment is relative to the parent and may contain :name placeholders. component is a ComponentSlot; its E/R channels are recovered and propagate up the tree.
The returned RouteNode is also the reference passed to href.
A leaf component reads the live match in either of two forms:
- Handler-arg props: declare
(props:RouteHandlerProps<Path, Query>)and the router passes the decoded{ path, query }straight in.path/queryare inferred from the route'spath/queryfields. A plain zero-arg thunk works too, ignoring the props. - Dependency injection: a
Component.make/Component.gencomponent reading the live match viaRouter.params/Router.query. Required for layouts/deep nodes, which can't take handler args.
// Handler-arg props: decoded { path, query } passed in directly.
const userRoute = Router.route("users/:id", {
path: { id: Schema.NumberFromString },
query: { tab: Schema.optional(Schema.String) },
component: ({ path, query }) => h.div(`User ${path.id} (${query.tab ?? "info"})`),
});
// Dependency injection: read the match anywhere via Router.params / Router.query.
const userRouteDI = Router.route("users/:id", {
path: { id: Schema.NumberFromString },
component: Component.gen(function* () {
const { id } = yield* Router.params({ id: Schema.NumberFromString });
return yield* h.div(`User ${id}`);
}),
});Router.layout
Router.layout<C, S>(config: { component: S }, children: C): LayoutNode<E, R>;Declares a layout: purely UI nesting, owning no path or segment. component splices the injected outlet via yield* Router.Outlet.
Router.Outlet is excluded from the layout's aggregate requirement channel (the router discharges it per render); the subtree's real channels are unioned in.
Router.router
Router.router<T, NF>(root: T, options: { notFound: () => NF }): RouterDef<E, R>;Seals a route tree into a RouterDef, compiling it eagerly (so leaf references are stamped for href) and capturing the app-level not-found page. The tree's aggregate channels (plus the not-found page's) ride on the returned RouterDef's phantom E/R.
Router.lazy
Router.lazy<S extends ComponentSlot>(
load: () => Promise<S>,
): () => Node<Node.Error<SlotNode<S>>, Node.Context<SlotNode<S>>>;Wraps a dynamic-import loader as a component slot. The route's component is code-split into its own chunk while the descriptor (segment + param schemas) stays eager.
load returns a Promise resolving the component, typically () => import("./page").then((m) => m.Page). Drops directly into Router.route({ component }) and Router.layout({ component }).
- Channels preserved. The returned slot's
E/Requal the resolved component's, so a lazy route has the identical type to declaring it eagerly. An unmet requirement is still a compile error atRouter.router. - Only the matched branch loads, on the server during render and on the client on navigation. Client navigation is deferred-commit: the chunk and the leaf component's own effect resolve before the URL commits (see
Router.navigatingand Blocking vs streaming data). The previous page stays mounted through both the fetch and any data the leaf awaits, so the swap is blank-free. The loadPromiseis memoized per slot, so revisits are synchronous. - A rejected
loadis a defect (Effect.promisedies): a deploy-skew/offline condition surfaced through normal defect handling, kept off theEchannel. The rejection is memoized (no silent retry). - The resolved value should be a
Component(Component.make/Component.gen); a bare() => Nodethunk loses its channels through the loaderPromise, so wrap it inComponent.make.
See the Split Routes Lazily how-to and packages/router/src/lazy-component.specs.md.
Router.Outlet
A Context.Service key whose value is the node to splice for the next level down. A layout (or the server document shell) reads it with const outlet = yield* Router.Outlet. Typed opaque as Node<never, never>, and discharged by the router at render time, so it never appears in a reader's aggregate requirement channel.
Router.params / Router.query
Router.params<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;
Router.query<F extends Fields>(fields: F): Effect<FieldsType<F>, RouterParamsError, Router>;Snapshot accessors that read the live match (Subscribable.get(currentMatch)) and pick the requested fields keys from the decoded path/query. The matcher already decoded the values against the leaf's full schema, so they are returned directly (no re-validation).
Readable from any component, not just the leaf: this is the dependency-injection path layouts and deep nodes use (leaves can instead take handler-arg props). They fail with a RouterParamsError (source: "path" | "query", plus the requested keys) when no route matches.
Router.paramsStream / Router.queryStream
Router.paramsStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;
Router.queryStream<F extends Fields>(fields: F): Effect<Subscribable<FieldsType<F>>, never, Router>;The reactive counterparts. Each resolves a Subscribable<FieldsType<F>> derived from Subscribable.changes(currentMatch). A component can render [Subscribable.changes(yield* Router.queryStream(fields))] and update in place even when the outlet keeps the same leaf mounted. That is exactly the query-only case (setQuery / patchQuery) a snapshot Router.query would miss.
Resilient across navigations: a NotFound match yields the empty subset rather than failing, so the stream stays live.
Router.navigating
type NavState =
| { readonly _tag: "Idle" }
| { readonly _tag: "Navigating"; readonly to: string };
// on the Router service:
readonly navigating: Subscribable.Subscribable<NavState>;
// accessor, mirroring paramsStream/queryStream:
Router.navigatingStream: Effect<Subscribable<NavState>, never, Router>;The reactive navigation-state signal, for rendering pending UI (a top progress bar, dimmed outlet) during a deferred-commit navigation. It transitions Idle → Navigating{ to } while the router resolves the target branch's Router.lazy chunk and the matched leaf's own component effect, then back to Idle on commit. One signal covers both the code and data windows.
NavState is exported from @weftui/router and @weftui/router/client.
- Eager navigations never flip it: a branch with no lazy node and a synchronously-resolving leaf commits synchronously and
navigatingstaysIdle. Reading it costs nothing in an eager app. - Synchronous resolutions never emit
Navigating: a revisit with both the chunk and the leaf's effect memoized (or an eager leaf with no async work) still commits in the same tick; the signal only flips when a resolution is genuinely async. - Latest-wins across rapid navigations: a superseded navigation never resets it and its pre-run is interrupted.
- popstate (back/forward) into a lazy or data-fetching route also reports.
- A rejected chunk load or a failing leaf pre-run (typed error or defect) resets it to
Idlebefore the failure surfaces through the normal render error path. - Server-side it is a constant
Idle(server render is buffered), so a component reading it type-checks and renders on both sides.
See the Show Navigation Progress how-to, the Blocking vs streaming data section below, and packages/router/src/resolve-before-commit.specs.md.
Blocking vs streaming data
A leaf component is already an Effect; there is no separate loader. Where you put an await decides whether it blocks the navigation commit or streams in afterward:
yield*in the component body → commit-blocking. The pre-run executes the leaf's effect to completion before the URL commits. The previous page stays mounted for the whole window, andRouter.navigatingreportsNavigating{ to }for its duration.- An
Effect/Streamplaced as a child node → streaming. The commit is not delayed; the leaf mounts immediately and the child region fills in place once its own effect resolves.
// Blocking: the await is in the leaf's own body; navigation waits for it.
const DocPage = Component.gen(function* () {
const { category, slug } = yield* Router.params({ category: Schema.String, slug: Schema.String });
const docs = yield* Docs;
const doc = yield* docs.load(category, slug);
return yield* h.article([h.h1(doc.title), h.div({ innerHTML: doc.html })]);
});
// Streaming: the await lives on a child; the leaf commits immediately.
const DocPage = Component.gen(function* () {
const { category, slug } = yield* Router.params({ category: Schema.String, slug: Schema.String });
const docs = yield* Docs;
return yield* h.article([
h.h1("Docs"),
Stream.concat(
Stream.make(h.p({ class: "loading" }, "Loading…")),
Stream.fromEffect(
Effect.map(docs.load(category, slug), (doc) => h.div({ innerHTML: doc.html })),
),
),
]);
});Choose blocking for primary content the page is meaningless without (an article body, a product's price); the old page stays visible with no blank or skeleton. Choose streaming for secondary or slow regions where partial content is still useful (a comments panel, a "related" rail); the commit isn't held hostage by one slow fetch.
Only the matched leaf is pre-run this way. Layout components in the branch get their chunks preloaded, but their bodies still run at render, post-commit. Unchanged layouts don't re-render across navigations, so this rarely matters in practice.
Pre-run failures (notFound(), a typed error, or a defect) still commit the URL. They replay through the normal render error path (the nearest Boundary, or the router's 404 boundary) without re-running the component.
See Load Async Data and RPC Data Boundaries for the streaming patterns in full.
href
href<Path, Query>(ref: RouteNode<Path, Query>, args?: HrefArgs<Path, Query>): string;Builds a type-safe URL for a leaf route reference. Path params encode into the pattern; query values encode through the query schema into a key-sorted search string. Round-trips with the matcher.
pathis required when its decoded type has required keys;queryis optional when every query field is optional (HrefArgs).- Throws if the leaf belongs to a tree that has not been sealed with
Router.router().
href(userRoute, { path: { id: 42 } }); // "/users/42"
href(postsRoute, { path: { id: 1 }, query: { sort: "new" } }); // "/users/1/posts?sort=new"Universal nodes
RouterApp
RouterApp<E, R>(def: RouterDef<E, R>): Node<Exclude<E, RouterNotFound>, R | Router>;The universal router root node; render this on both server and client. Wraps the nested outlet in the router's internal not-found boundary, so a RouterNotFound raised by a page renders the configured notFound page in place. Server dispatch runs through HttpApiBuilder: a page-raised RouterNotFound and a no-match surface their 404 through the platform request pipeline.
RouterApp requires Router in its environment. Provide it via RouterLive (client) or RouterServer (server), not Effect.provide at the node level. Services under WeftApp come exclusively from the app's layer, not ambient Effect.provide.
outletNode (a.k.a. RouterOutlet)
outletNode<E, R>(def: RouterDef<E, R>): Node<E | RouterNotFound, R | Router>;The bare nested-outlet node without the internal not-found boundary, for callers placing their own not-found handling. Re-exported from @weftui/router/client as RouterOutlet.
Client: @weftui/router/client
RouterLive
RouterLive(
def: RouterDef,
options: { rpc: { group: RpcGroup<any> }; baseUrl?: string | URL },
): Layer.Layer<Router | AppRpcClientTag>;The client Router layer, backed by the History API. Seeds a SubscriptionRef from window.location, listens for popstate, installs the same-origin link-click interceptor, and derives the HttpApiClient exposed as Router.httpApiClient (over FetchHttpClient; baseUrl defaults to same-origin).
Alongside Router it also provides the core AppRpcClientTag seam: a network flat rpc client over the app's merged RpcGroup (RpcClient.make → POST /_eui/rpc). This lets @weftui/dom resolve a Boundary.rpc (hydrated refetch and client-first SPA mount) without depending on this package or effect/unstable/rpc. Pass the same merged group the server wires into RouterServer.
Scoped: it must outlive the mount. Give it to WeftApp.make and the app runtime owns its lifetime (built lazily on first mount, released at WeftApp.dispose):
const app = WeftApp.make(RouterLive(App, { rpc: { group: StockRpcs } }));
void Effect.runPromise(WeftApp.hydrate(app, RouterApp(App), root));Programmatic navigation
navigate<Path, Query>(ref: RouteNode<Path, Query>, ...args): Effect<void, never, Router>;
push(to: string): Effect<void, never, Router>;
replace(to: string): Effect<void, never, Router>;
back(): Effect<void>;
forward(): Effect<void>;
setQuery(query: Record<string, unknown>, options?: NavigateOptions): Effect<void, never, Router>;
patchQuery(partial: Record<string, unknown>, options?: NavigateOptions): Effect<void, never, Router>;Typed programmatic navigation, all run within the RouterLive layer (except back/forward, which only touch window.history):
navigate(ref, args): go to a leaf routerefwith typed{ path, query }. Builds the URL viahref(so it round-trips withmatch) and pushes the History entry, or replaces it withoptions.replace. Same requiredness rules ashref:pathrequired when the route has path params,queryoptional when every field is optional.push/replace: go to a rawpath + searchstring, pushing or replacing the History entry.back/forward: step through History (history.go(±1)); thepopstatehandler resyncs.setQuery/patchQuery: change the current route's query in place, re-encoding through the matched leaf'squerySchema. The path is kept, so the leaf stays mounted and reactivequeryStreamreaders update.setQueryreplaces the query;patchQuerymerges. No-op when no route is matched.
import { navigate, patchQuery, push } from "@weftui/router/client";
yield * navigate(userRoute, { path: { id: 42 }, query: { tab: "posts" } });
yield * push("/users/1/posts?sort=new");
yield * patchQuery({ sort: "old" }); // keeps the current path + other query fieldsScroll reset
Every client-committed navigation whose path differs from the previously committed path (navigate, push, replace, and the link interceptor) resets window.scrollTo(0, 0) synchronously at commit. Query-only navigations (setQuery / patchQuery, or any push/replace to the same path) preserve scroll.
popstate (back / forward) never resets; the browser's own history.scrollRestoration: "auto" restores the prior offset for those entries. Server rendering is unaffected. Not configurable: there is no NavigateOptions field to opt out.
installLinkInterceptor
installLinkInterceptor(def: RouterDef, navigate: (to: string) => Effect<void>): Effect<void, never, Scope>;The delegated click interceptor RouterLive installs for you. Exposed for advanced/manual wiring. Intercepts plain same-origin clicks whose href resolves to a route (resolved against def). It leaves modified clicks, target=_blank, download, external origins, same-document navigations, and non-matching hrefs to the browser.
Server: @weftui/router/server
RouterServer
A namespace for server-side rendering of a RouterDef.
RouterServer.render(def, options: { document; rpc; url }): Effect<{ html; status }, Error>;
RouterServer.toWebHandler(def, options: { document; rpc }): (request: Request) => Promise<Response>;Dispatch runs through the def.httpApi spine via HttpApiBuilder: platform owns request→leaf matching and path/query decode. Each leaf handler then builds a fixed-match server Router and renders the universal outlet to hydratable HTML.
toWebHandlerreturns a Webfetch-style handler(Request) => Promise<Response>that dispatches throughHttpApiBuilder.toWebHandlerand repliestext/html. Suitable for bridging into a dev server (Vite) or any Web-platform server.renderdrives that handler for a singleurl, returning{ html, status }with<!DOCTYPE html>prepended.statusis sourced from the platform pipeline:200, or404for a no-match / a page-raisedRouterNotFound.documentis aComponentSlotthat splices the app viayield* Router.Outlet; the router provides bothRouter.Outlet(the app, per request) andRouter.rpcis the app'sBoundary.rpcdata foundation:{ group: RpcGroup<any>; handlers: Layer<any, never, never> }. That is the mergedRpcGroup(shared with the client) plus its server-only handler Layer (group.toLayer(...)⊕ its dependencies).toWebHandlerserves the handlers atPOST /_eui/rpc, so a client refetch / client-first mount re-runs them on the server. An in-process client over the same handlers (backing theAppRpcClientTagseam) resolves SSR boundaries in-process, never over the network.
The
HttpApiis not generated on the server. It is built once when the tree is sealed (buildHttpApiduringRouter.router) and lives ondef.httpApi. That is the single source of truth both the server dispatch and the client matcher / derivedHttpApiClientread from.
Errors
RouterNotFound
Schema.TaggedErrorClass with an optional path: string. Raised by notFound or when no route matches. Caught by the router's internal not-found boundary; export it to place your own Boundary.catchTag("RouterNotFound", …) (a nearer user boundary wins).
RouterParamsError
Schema.TaggedErrorClass with source: "path" | "query" and keys: readonly string[]. Raised by Router.params / Router.query when the live match doesn't satisfy the requested fields. Bubbles into the tree's aggregate error channel.
notFound
notFound(path?: string): Effect<never, RouterNotFound>;Short-circuits the current page render with a RouterNotFound. Callable from any page or layout component; the server responds with HTTP 404.
isRouterNotFound
isRouterNotFound(u: unknown): u is RouterNotFound;Type guard recognising a RouterNotFound value regardless of its prototype.
Compilation & matching (advanced)
These power the runtime and are exported for tooling/tests; most apps never touch them directly.
| Export | Description |
|---|---|
compile(def) |
Walks a tree into flat CompiledLeafs with merged path/query schemas and layout chains. |
buildHttpApi(leaves) |
Builds the authoritative HttpApi (one "pages" group, a GET endpoint per leaf with params/query schemas + 404). Called by Router.router; the result is def.httpApi. |
leafRegistry |
WeakMap<RouteNode, CompiledLeaf> read by href to resolve a leaf's pattern/schemas. |
match(compiled, url) |
Resolves a URL to a RouteMatch (Matched with decoded path/query, or NotFound). |
compileMatchers(compiled) |
Precompiles per-leaf regex matchers. |
Types
| Type | Description |
|---|---|
RouterDef<E, R> |
A sealed, compiled router. The unit passed to client and server; phantom E/R carry the tree's aggregate channels. |
RouteNode<Path, Query, E, R> / LayoutNode<E, R> / TreeNode |
Authored tree nodes. |
ComponentSlot<N> |
A (props: any) => N callable producing a Node; accepts a plain thunk or a Component.make / Component.gen component. |
RouteHandlerProps<Path, Query> |
The { path, query } decoded match a leaf component may declare as handler-arg props. |
RouteMatch |
{ _tag: "Matched"; leaf; path; query; url } or { _tag: "NotFound"; url }. |
HrefArgs<Path, Query> |
The href argument object; path/query become optional when their decoded type has no required keys. |
NavigateOptions |
{ replace?: boolean }, for navigate / setQuery / patchQuery and Router.navigate. |
RouterHttpApiClient |
The platform HttpApiClient derived from a router's HttpApi spine (carried opaquely on Router.httpApiClient). |
Fields / FieldsType<F> |
Schema.Struct.Fields and the Type side of its Schema.Struct. |
Compiled / CompiledLeaf / CompiledLayout |
The compiled tree shapes. |
RouterOptions |
Options for Router.router (notFound). |
See also
- Add Routing: the narrative guide to authoring a route tree
- Split Routes Lazily · Show Navigation Progress:
Router.lazyandRouter.navigating @weftui/corereference ·@weftui/domreferencepackages/router/router.specs.md: the full specification