
# 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:

```ts
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.

> [!TIP]
> Write `load` before `view` in an entry. TypeScript infers the data type from the fields in the order they are written, so the view, and the page component it renders, then see `data` typed.

### 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:

```tsx
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](/guide/templates/) 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:

```tsx
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:

```ts
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](#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](/guide/observability/) |
| `window` | The global window | `null` attaches to none, as on the server |

> [!WARNING]
> These modules import each other in a loop: `router.ts` imports the route table, the table imports the page components, and a component that builds links imports `router.ts` back. The loop is safe while components read `router` inside their functions, which run after every module has loaded, as every component on this page does. A use at a module's top level, such as `const home = router.href("home");` outside any function, runs before the router exists, and the app fails to start with an error such as `Cannot access 'router' before initialization`.

`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:

```tsx
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:

```tsx
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:

```ts
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](/guide/deploying/) 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](/guide/server-rendering/)). `router.ready` settles once the initial navigation, loader included, has committed; await it before hydrating.

