Routing
@zerolith/router is the router of a single-page application. Pages are components, the route table maps each path to one, and a layout component places the current page. Parameters come from the path strings, so router.href("release", { tag }) fails to type-check on a misspelled route or a missing parameter.
The examples on this page are one small application, in the files a project from zerolith new keeps under src/: the route table, a component per page, the router, and the layout.
The route table
src/routes.ts maps each path to the component that renders it, with an optional loader and the page's title:
import { defineRoutes, lazy, route } from "@zerolith/router";
import { Home } from "./components/home.tsx";
import {
ReleasePage, loadRelease, releaseHead
} from "./components/release.tsx";
export const routes = defineRoutes({
home: route({ path: "/", view: Home, head: { title: "Releases" } }),
release: route({
path: "/releases/:tag",
load: loadRelease,
view: ReleasePage,
head: releaseHead
}),
settings: route({
path: "/settings",
view: lazy(() => import("./components/settings.tsx")
.then((module) => module.Settings)),
head: { title: "Settings" }
})
});Each entry is a route() with up to four fields:
| Field | Takes | What it does |
|---|---|---|
path |
A pattern, such as "/releases/:tag" |
Matched against the URL; its parameters become params |
view |
A function of { params, data, url }, or lazy(() => import(...)) |
Renders the page, once per navigation to the route |
load |
An async function of { params, url, abort } |
Runs before the URL changes; what it resolves to is the view's data |
head |
{ title, description }, or a function of { params, data, url } that returns it |
The document's title and description while the route is shown |
A page component is a view as it is, and a page small enough to write in place can be one, as view: () => <h1>About</h1>. A route's load and lazy view resolve before the URL changes, and router.pending is true meanwhile. A view declared with lazy() loads as a chunk of its own the first time a reader opens it, and zerolith build and the Vite plugin fail a build that also imports it statically, with lazy_module_not_split, instead of quietly bundling it into the page.
Path patterns
| Pattern | Matches | Params |
|---|---|---|
/ |
The root | {} |
/settings |
/settings and /settings/; matching is case-sensitive |
{} |
/releases/:tag |
One segment after /releases/, as /releases/1.0 |
{ tag: "1.0" } |
/files/*path |
Everything after /files/, slashes included, as /files/docs/a.md |
{ path: "docs/a.md" } |
Parameter values are decoded from the URL, and href encodes them back, a *rest value one segment at a time, so its slashes stay slashes. A path whose percent escapes are malformed matches no route and renders the fallback. Routes are tried in the order the table lists them, and the first match wins, so a *rest route that would also match a more specific path goes after it.
Pages are components
src/components/release.tsx is the release page. It receives the path's parameters and the loader's data as its props, typed by the route, and keeps its loader and its title beside it:
import { type View, computed, signal, when } from "zerolith";
import type { Head, LoadContext, RouteContext } from "@zerolith/router";
import { type Release, findRelease } from "../releases.ts";
type Params = { tag: string };
type Loaded = Release | undefined;
type Props = RouteContext<Params, Loaded>;
/** Loads the release the path names; the page shows once it resolves. */
export function loadRelease({ params }: LoadContext<Params>): Promise<Loaded>
{
return findRelease(params.tag);
}
/** The page title, from the loaded release. */
export function releaseHead({ data }: Props): Head
{
return { title: data?.name ?? "Unknown release" };
}
/** One release, with its notes behind a toggle. */
export function ReleasePage({ params, data }: Props): View
{
if (data === undefined)
{
return <h1>No release named {params.tag}</h1>;
}
const open = signal(false);
return (
<article>
<h1>{data.name}</h1>
<button on:click={() => open.set(!open.get())}>
{computed(() => (open.get() ? "Hide notes" : "Show notes"))}
</button>
{when(open, () => <Notes items={data.notes} />)}
</article>
);
}
function Notes({ items }: { items: readonly string[] }): View
{
return <ul>{items.map((item) => <li>{item}</li>)}</ul>;
}A page component receives the route's context as its props:
| Prop | Holds |
|---|---|
params |
The path's parameters, as strings, typed from the path |
data |
What load resolved to, typed from the loader, or undefined for a route without one |
url |
The page's full URL, with its query string and hash |
A loader receives params and url too, and abort, an AbortSignal that fires when a newer navigation replaces this one. Pass it to fetch, so a request nobody is waiting for stops.
A page composes other components as any component does, as ReleasePage does with Notes. With html templates instead of JSX, a component is called as the function it is, ${Notes({ items: data.notes })}; the templates guide shows both spellings.
Every page renders inside an owner of its own, so its signals, effects, and onDispose cleanups end when the reader moves to another route. The settings page starts a clock and leaves stopping it to onDispose, and navigates from code when the reader is done:
import { type View, onDispose, signal } from "zerolith";
import { router } from "../router.ts";
/** Loaded on its first visit. Its clock stops when the reader leaves. */
export function Settings(): View
{
const now = (): string => new Date().toLocaleTimeString();
const time = signal(now());
const clock = setInterval(() => time.set(now()), 1000);
onDispose(() => clearInterval(clock));
const done = (): Promise<void> => router.navigate(router.href("home"));
return (
<section>
<h1>Settings</h1>
<p>The time is {time}.</p>
<button on:click={done}>Done</button>
</section>
);
}The layout
src/router.ts creates the router over the route table, and its fallback renders when no route matches:
import { createRouter } from "@zerolith/router";
import { NotFound } from "./components/not-found.tsx";
import { routes } from "./routes.ts";
export const router = createRouter(routes, {
fallback: (url) => NotFound({ path: url.pathname })
});| Option | Default | What it does |
|---|---|---|
fallback |
Nothing renders | Renders when no route matches, given the URL |
base |
"/" |
The path the application is served under; see Under a base path |
onError |
navigate rejects |
Receives the error and the URL when a loader, a lazy import, or a route's head throws, or when the new page throws as it renders |
scroll |
true |
false leaves scrolling to the application |
observe |
None | Receives each navigation's start, commit, error, and cancel; see Observability |
window |
The global window | null attaches to none, as on the server |
src/layout.tsx places the current page, router.outlet(), under the navigation, and router.matches() tells each link whether its page is the one shown:
import { type View, computed } from "zerolith";
import { router } from "./router.ts";
/** A link that marks itself while its page is shown. */
function NavLink({ href, label }: { href: string; label: string }): View
{
const here = router.matches(href, { exact: true });
const current = computed(() => (here.get() ? "page" : undefined));
return <a href={href} aria-current={current}>{label}</a>;
}
/** The frame around every page: the navigation and the current page. */
export function Layout({ content }: { content: View }): View
{
return (
<>
<nav>
<NavLink href={router.href("home")} label="Releases" />
<NavLink href={router.href("settings")} label="Settings" />
</nav>
<main>{content}</main>
</>
);
}src/main.tsx waits for the first route and renders the layout:
import { render } from "zerolith";
import { Layout } from "./layout.tsx";
import { router } from "./router.ts";
await router.ready;
render(() => <Layout content={router.outlet()} />, "#app");What the router offers
| Member | What it is |
|---|---|
outlet() |
The current page as a view, for the layout to place |
href(name, params) |
The path of a named route, with the base and the parameters encoded; a misspelled name or a missing parameter fails to type-check |
navigate(to, { replace }) |
Goes to a path or URL after the route's loader, and resolves once the page has changed; replace replaces the history entry instead of adding one. A URL on another origin is left to the browser, through location.assign, or location.replace with replace |
matches(path, { exact }) |
A signal, true while the URL is at path or below it, segment by segment, so /notes covers /notes/7 and not /notesy; exact requires the path itself |
current |
A signal of the current match, with its name, params, url, and data, or undefined while the fallback shows |
url |
A signal of the current URL |
pending |
A signal, true while a navigation loads |
head |
A signal of the current route's title and description |
ready |
A promise that settles once the first navigation, loader included, has committed |
dispose() |
Detaches the router from the window |
Links and navigation
Plain anchors navigate, as the links in the layout and the home page do. One delegated listener handles same-origin links clicked without modifier keys and leaves download, target, and rel="external" links, and any link with a modifier key, to the browser. Code navigates with router.navigate, as the settings page does.
Back and forward restore the scroll position, and so does a reload, since the router keeps each position in its history entry's state; a new navigation scrolls to the top or to the hash. The first navigation of a fresh visit leaves the scroll where the browser and the reader put it, since a server-rendered page is readable, and scrollable, before the router starts. A link to a hash on the page shown is left to the browser, and back and forward between the entries it adds move to the hash without loading the page again. Pass scroll: false to keep control of scrolling.
A navigation whose loader fails calls onError with the error and the URL; without onError, navigate rejects. The route's head runs before the URL changes, so one that throws fails the navigation the same way and leaves the page as it was. A navigation that a newer one replaces before it commits is canceled rather than failed: its loader's abort signal fires as the newer one starts, wherever it goes, and onError is not called for it.
Under a base path
An application served under a path, such as /app/, passes it as base; import.meta.env.BASE_URL carries the base zerolith dev, zerolith build, and Vite were given, so one line follows all of them:
const router = createRouter(routes, { base: import.meta.env.BASE_URL });Route paths stay relative to the base: "/notes/:id" matches /app/notes/7, and href("note", { id: "7" }) returns /app/notes/7. navigate and matches take a path with the base or without it, so navigate("/notes/8") and navigate("/app/notes/8") go to the same place. A URL outside the base renders the fallback, and a link outside it is left to the browser, so the rest of the site keeps working around the application. Building and serving covers setting the base for the build and the server.
Head metadata
head sets the document title and description per route, as a static object, as the home route's is, or as a function of the parameters and loaded data, as releaseHead is.
The same metadata is router.head, a signal the server renders with renderHead from @zerolith/router/server (see Server rendering). router.ready settles once the initial navigation, loader included, has committed; await it before hydrating.