@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.Tag 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:Option.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/queryinferred 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, so a 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 (see
Router.navigating): the chunk resolves before the URL commits, so the previous page stays mounted and 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— wrap it inComponent.make.
See the Split Routes Lazily how-to and packages/router/src/lazy-component.specs.md.
Router.Outlet
A Context.Tag 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 (currentMatch.get) 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 currentMatch.changes, so a component can render [(yield* Router.queryStream(fields)).changes] and update in place even when the outlet keeps the same leaf mounted — 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 a Router.lazy chunk in the target branch, and back to Idle on commit. NavState is exported from @weftui/router and @weftui/router/client.
- Eager navigations never flip it — a branch with no lazy node commits synchronously and
navigatingstaysIdle, so reading it costs nothing in an eager app. - Latest-wins across rapid navigations (a superseded navigation never resets it); popstate (back/forward) into a lazy route also reports; a rejected chunk load resets it to
Idlebefore the defect surfaces. - 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 and packages/router/src/pending-navigation.specs.md.
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 (that would release the scoped layer immediately).
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) — so @weftui/dom can resolve a Boundary.rpc (hydrated refetch and client-first SPA mount) without depending on this package or @effect/rpc. Pass the same merged group the server wires into RouterServer. Scoped — it must outlive the mount, so provide it through a ManagedRuntime:
const runtime = ManagedRuntime.make(RouterLive(App, { rpc: { group: StockRpcs } }));
void runtime.runPromise(hydrate(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 }, building the URL viahref(so it round-trips withmatch) and pushing — or, withoptions.replace, replacing — the History entry. 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 fieldsinstallLinkInterceptor
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); 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, then each leaf handler 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> }— 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), and 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.httpApias the single source of truth both the server dispatch and the client matcher / derivedHttpApiClientread from.
Errors
RouterNotFound
Schema.TaggedError 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.TaggedError 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 setPath/setUrlParams + 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