Routing
@weftui/router is a universal (server + client) nested router for Weft. It maps a URL to a rendered Node tree on both sides:
- Server — matches an incoming request path, renders the matched nested page to hydratable HTML, and responds with
text/html(HTTP 404 for not-found). - Client — matches
window.location, swaps pages reactively via the History API, and keeps unchanged ancestor layouts mounted across navigations.
The package mirrors @weftui/dom: a shared (universal) root, a ./client entry, and a ./server entry.
npm install @weftui/routerThe mental model
A route's component is its handler — a page is a component that renders, and its component slot is invoked at render time on whichever side the request arrives. Server-resolved data stays with Boundary.rpc; client-side async stays with Boundary.suspend.
You author an explicit nested route tree with three namespaced combinators — mirroring the h.div / Component.gen / Boundary.catchTag surface — and seal it once:
| Combinator | Builds |
|---|---|
Router.route(segment, { path?, query?, component }) |
A leaf page. |
Router.layout({ component }, children) |
A layout that wraps an outlet (purely UI nesting — owns no path). |
Router.router(root, { notFound }) |
Seals the tree into a RouterDef. |
The tree is the source of truth. The same sealed RouterDef drives both server and client.
Authoring routes
Every component slot is a ComponentSlot — a callable producing a Node, passed uncalled. Use Component.make / Component.gen (or a plain () => Node thunk). The router invokes it at render time, which is what lets href(…) resolve after the tree is compiled.
import { Component, h } from "@weftui/core";
import { Router } from "@weftui/router";
import { Schema } from "effect";
const About = Router.route("about", {
component: Component.make(() => h.h1("About")),
});
const User = 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}`);
}),
});segmentis relative to the parent and may contain:namepath-param placeholders (e.g."users/:id"). A leading/trailing/is tolerated. Each leaf carries its full relative path (e.g."users/:id/settings").path/queryareSchema.Struct.Fields(a record ofname → Schema), declared only on routes. The compiler covers every:nameplaceholder inpathSchema, defaulting toSchema.Stringwhen a placeholder has no declared field. Query fields are optional by default.
Authoring components with
Component.make/Component.genkeeps every slot fully typed: the router never sees aNode<any, any>, and each component'sE/Rchannels aggregate up throughRouter.layout/Router.routerinto the sealedRouterDef.
Reading the match: handler-arg props vs. injection
A leaf page reads the current match's decoded path / query in either of two forms.
Handler-arg props (leaf pages)
The router passes the decoded { path, query } straight into a leaf component as props (typed RouteHandlerProps<Path, Query>, inferred from the route's path / query fields). No Router access, no validation step — just read the props:
const idParam = { id: Schema.NumberFromString };
const sortQuery = { sort: Schema.optional(Schema.String) };
Router.route("users/:id/posts", {
path: idParam,
query: sortQuery,
// `path.id` is already a number; `query.sort` is `string | undefined`.
component: ({ path, query }) =>
h.section([h.h2(`Posts for user ${path.id}`), h.p(`sort: ${query.sort ?? "none"}`)]),
});This is the most direct form for a leaf. A plain zero-arg thunk works too — it just ignores the props.
Dependency injection (layouts and deep nodes)
A layout sits above the leaf and so can't take handler args; it reads the match by dependency injection instead. Router.params(fields) / Router.query(fields) are readable from any component:
// A /users/:id layout (above the leaf) reads `:id` by injection.
const UserShell = Component.gen(function* () {
const { id } = yield* Router.params(idParam);
const outlet = yield* Router.Outlet;
return yield* h.div({ class: "user" }, [h.h1(`User ${id}`), outlet]);
});Router.params(fields) / Router.query(fields) read the live match and pick the requested fields keys (already decoded by the matcher, so no re-validation). They return the typed values, or fail with a tagged RouterParamsError (carrying source: "path" | "query" and the requested keys) when no route matches. That error bubbles into the app node's aggregate E, so a user may recover it with Boundary.catchTag("RouterParamsError", …).
Reactive accessors.
Router.paramsStream(fields)/Router.queryStream(fields)are the reactive counterparts — each resolves aSubscribablederived fromcurrentMatch.changes, so a component can render[(yield* Router.queryStream(sortQuery)).changes]and update in place even when the same leaf stays mounted (the query-only caseRouter.querywould miss). See Programmatic navigation.
Layouts and the outlet
A layout wraps the next level down — the outlet — which is also delivered by injection. A layout reads it with yield* Router.Outlet and places it like any h-style child:
const UserShell = Component.gen(function* () {
const { id } = yield* Router.params(idParam);
const outlet = yield* Router.Outlet;
return yield* h.div({ class: "user" }, [h.h1(`User ${id}`), outlet]);
});
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]);Router.Outlet is typed opaque (Node<never, never>), so splicing it adds nothing to the layout's own channels — the subtree's real E/R are aggregated structurally by Router.layout. The router discharges the Outlet requirement at render time, so it never appears in a layout's (or the sealed app's) aggregate requirement channel.
A layout owns no segment or path — all path structure lives on routes. A layout that needs a param simply reads it via Router.params.
Layout persistence
Each nesting level renders as a reactive stream child keyed by (pattern + the param values that level depends on) and deduped. An unchanged ancestor layout therefore stays mounted across a navigation that only changes a deeper level: its DOM identity and any local state (a SubscriptionRef, a scroll position) survive while only the inner outlet swaps.
Sealing the tree
Router.router(root, { notFound }) compiles the tree eagerly (stamping leaf references so href works) and captures the app-level not-found page:
export const App = Router.router(
Router.layout({ component: Shell }, [
homeRoute,
Router.layout({ component: UserShell }, [settingsRoute, postsRoute]),
]),
{ notFound: () => h.section({ id: "page" }, [h.h2("404 — page not found")]) },
);App is a RouterDef whose phantom E/R carry the aggregate channels of the whole tree (plus the not-found page) — keep app.ts side-effect-free (no mount/hydrate) so both entries can import it.
Type-safe links with href
href(leafRef, args) builds a URL from a leaf route reference (the value returned by Router.route). Path params are required in the argument type and query is optional when every query field is optional:
import { href } from "@weftui/router";
const Home = Component.make(() =>
h.nav([
h.a({ href: href(settingsRoute, { path: { id: 1 } }) }, "User 1 settings"),
h.a({ href: href(postsRoute, { path: { id: 2 }, query: { sort: "new" } }) }, "User 2 posts"),
]),
);Path params encode into the pattern (/users/:id + { id: 42 } ⇒ /users/42); query values encode through the query schema into a key-sorted search string. href round-trips with the matcher. The leaf must belong to a tree sealed with Router.router() (which is why deferring the component body via Component.make matters — href runs at render time, after compile).
Not-found
notFound(path?) short-circuits the current render with a RouterNotFound failure. Callable from any page or layout; the nearest enclosing not-found boundary renders the configured notFound page in its place, and the server responds with HTTP 404:
import { notFound, Router } from "@weftui/router";
Router.route("users/:id", {
path: idParam,
component: Component.gen(function* () {
const { id } = yield* Router.params(idParam);
if (id < 0) return yield* notFound();
return yield* h.div(`User ${id}`);
}),
});RouterNotFound is exported, so a Boundary.catchTag("RouterNotFound", …) placed inside a subtree overrides the app-level fallback for that subtree (the router's internal boundary is outermost, so a nearer user boundary wins).
Client setup
On the client, provide the Router via RouterLive(def) and render RouterApp(def). RouterLive is a scoped layer — it owns the popstate listener and the same-origin link-click interceptor — so it must outlive the mount. Provide it through a long-lived ManagedRuntime rather than Effect.provide at the node level:
// entry-client.ts
import { hydrate } from "@weftui/dom/client";
import { RouterApp, RouterLive } from "@weftui/router/client";
import { ManagedRuntime } from "effect";
import { App } from "./app";
const root = document.getElementById("root")!;
const runtime = ManagedRuntime.make(RouterLive(App));
void runtime.runPromise(hydrate(RouterApp(App), root));For a client-only app (no SSR), swap hydrate for mount — everything else is identical.
Link interception
A plain h.a({ href }) to a same-origin, route-matching URL performs SPA navigation when clicked — no full page load. The interceptor leaves the browser's native behaviour untouched for modified clicks (ctrl/meta/shift/alt or non-left button), target=_blank, download, external origins, same-document (hash-only) navigations, and hrefs that don't resolve to a route. You don't wire anything up: RouterLive installs the delegated listener for the layer's lifetime and removes it on teardown.
Programmatic navigation
For navigation that isn't a link click, @weftui/router/client exposes typed helpers. They run as Effects within the RouterLive layer (except back / forward, which only touch window.history):
import {
back,
forward,
navigate,
patchQuery,
push,
replace,
setQuery,
} from "@weftui/router/client";
// Typed: build the URL from a leaf ref + decoded args (same rules as `href`).
yield * navigate(postsRoute, { path: { id: 42 }, query: { sort: "new" } });
yield * navigate(settingsRoute, { path: { id: 42 } }, { replace: true });
// Raw path + search string.
yield * push("/users/1/posts?sort=new");
yield * replace("/users/1/settings");
// History stepping (popstate resyncs the router).
yield * back();
yield * forward();
// Change only the current route's query, re-encoded through its query schema.
// The path is kept, so the leaf stays mounted and `queryStream` readers update.
yield * setQuery({ sort: "old" }); // replaces the query
yield * patchQuery({ sort: "old" }); // merges into the current querynavigate(ref, args)builds the URL viahref(so it round-trips with the matcher) and pushes — or, with{ replace: true }, replaces — the History entry.argsfollows the same requiredness rules ashref.setQuery/patchQuerykeep the path, so the active leaf is never remounted — pair them withRouter.queryStreamfor in-place reactive updates. They are a no-op when no route is matched.
Server setup
On the server, RouterServer matches a request URL, builds a fixed-match Router, renders RouterApp to hydratable HTML inside a document shell, and reports a status (404 when no route matches or a page raises RouterNotFound).
The document shell is itself a ComponentSlot that splices the app via yield* Router.Outlet — exactly like a layout receives its outlet:
// entry-server.ts
import { Component, h } from "@weftui/core";
import { Router } from "@weftui/router";
import { RouterServer } from "@weftui/router/server";
import { Effect } from "effect";
import { App } from "./app";
const documentShell = Component.gen(function* () {
const app = yield* Router.Outlet;
return yield* h.html({ lang: "en" }, [
h.head([h.meta({ charset: "utf-8" }), h.title("My app")]),
h.body([
h.div({ id: "root" }, [app]),
h.script({ type: "module", src: "/src/entry-client.ts" }),
]),
]);
});
// { html, status } — `<!DOCTYPE html>` is prepended for you.
export const render = (url: string) =>
Effect.runPromise(RouterServer.render(App, { document: documentShell, url }));
// Or a Web fetch-style handler, ready to bridge into Vite or any Web server.
export const handler = RouterServer.toWebHandler(App, { document: documentShell });render provides both Router.Outlet (the app, per request) and Router (so the shell may read params), and renders through renderToStringHydratable so the client can hydrate in place.
@effect/platform is the spine
The tree is the authoring surface, but @effect/platform's HttpApi is the single source of truth for paths and schemas. Sealing the tree with Router.router(...) builds it once (buildHttpApi) and stamps it onto def.httpApi: a single "pages" group with one GET endpoint per leaf at its full path pattern, carrying setPath(pathSchema), setUrlParams(querySchema), and a RouterNotFound → 404 error. Both sides read that one definition, so they always agree:
- Server —
RouterServerdispatches throughHttpApiBuilder(platform owns request→leaf matching, path/query decode, and the 404 status). - Client —
RouterLivederives a realHttpApiClientfrom the samedef.httpApi(exposed asRouter.httpApiClient) for network work. SPA URL→leaf resolution stays local (there is no public client-side "match this URL against myHttpApi" utility in platform), fed from the same endpoint definitions so it never drifts from the server.
Errors
| Error | Raised by | Recover with |
|---|---|---|
RouterNotFound |
notFound(), or no route matched |
Boundary.catchTag("RouterNotFound", …) (or the app-level notFound page) |
RouterParamsError |
Router.params / Router.query on a missing/invalid key or no match |
Boundary.catchTag("RouterParamsError", …) |
Both are modeled as Schema.TaggedError, so they encode/decode across the wire the same way Boundary.rpc replays typed failures.
Boundary.rpc interplay
Initial SSR navigation works end to end: the server resolves the rpc and inlines its payload, and the client replays it during hydrate. Client-side navigation into a page containing a Boundary.rpc has no SSR payload, so the boundary performs a client-first mount — it renders the boundary's fallback, forks the rpc call over POST /_eui/rpc, and swaps in the result. @weftui/router provides the AppRpcClientTag seam on both sides (network client on the client, in-process on the server), so the same rpc backs SSR-replay, refetch, and client-first mount. See the rpc data boundaries guide.
See also
@weftui/routerAPI reference- examples/router-ssr — a runnable SSR + hydration app with nested layouts, persistent layout state, type-safe
hrefs, handler-arg props, and programmatic navigation over the@effect/platformspine - Component Authoring —
Component.make/Component.gen, the idiomatic way to write route components - Server-Side Rendering —
renderToStringHydratable,hydrate, andBoundary.rpc - RPC Data Boundaries —
Boundary.rpc, theResourcehandle, and the four lifecycles packages/router/router.specs.md— the full specification