---
title: "@ilha/router"
description: A lightweight isomorphic router for ilha apps with file-system routing, loaders, and SPA navigation.
order: 401
---

import { MultiCopy } from "imprensa/components";

# Router

`@ilha/router` is an isomorphic router for ilha apps: synchronous HTML on the server, signal-driven SPA navigation in the browser. It is a separate package from `ilha`, so you only add it when you need multi-page routing.

Every route is an ilha island. The router matches URLs, runs loaders, and mounts the active page. Route context (`routePath`, `routeParams`, and related signals) uses [`context()`](/guide/helpers/signals), so any island can read the current route without extra wiring.

## Install

<MultiCopy
  values={{
    npm: "npm install @ilha/router",
    pnpm: "pnpm add @ilha/router",
    bun: "bun add @ilha/router",
  }}
/>

## Import paths

| Import path             | Use it for                                                                        |
| ----------------------- | --------------------------------------------------------------------------------- |
| `@ilha/router`          | Runtime router, loaders, navigation helpers, route context, and built-in islands. |
| `@ilha/router/vite`     | Vite file-system routing plugin.                                                  |
| `@ilha/router/rspack`   | Rspack file-system routing plugin.                                                |
| `@ilha/router/rolldown` | Rolldown file-system routing plugin.                                              |

## Quick start

### Client-side SPA

```ts
import { router } from "@ilha/router";
import {
  HomePage,
  AboutPage,
  UserPage,
  NotFound,
} from "./pages";

router()
  .route("/", HomePage)
  .route("/about", AboutPage)
  .route("/user/:id", UserPage)
  .route("/**", NotFound)
  .mount("#app");
```

### Server-side rendering

```ts
import { router } from "@ilha/router";
import { HomePage, AboutPage, NotFound } from "./pages";

const html = router()
  .route("/", HomePage)
  .route("/about", AboutPage)
  .route("/**", NotFound)
  .render(request.url);

return new Response(
  `<!doctype html><html><body>${html}</body></html>`,
  {
    headers: { "content-type": "text/html" },
  },
);
```

### SSR + hydration (recommended)

```ts
// server entry — SSR / Nitro
import { pageRouter, registry } from "ilha:pages/server";
import "ilha:loaders"; // wires server-only loaders onto pageRouter

export default defineEventHandler(async (event) => {
  const html = await pageRouter.renderHydratable(
    event.node.req.url ?? "/",
    registry,
  );
  return new Response(
    `<!doctype html><html><body>${html}</body></html>`,
    {
      headers: { "content-type": "text/html" },
    },
  );
});
```

```ts
// client entry
import { pageRouter, registry } from "ilha:pages/client";

pageRouter.hydrate(registry);
```

## The `router()` builder

`router()` creates a fresh router instance and resets the route registry. Always call it fresh — never share instances across server requests.

### `.route(pattern, island, loader?)`

Registers a route. Uses [rou3](https://github.com/h3js/rou3) for matching — the same engine as Nitro. First match wins.

| Pattern         | Matches             | `routeParams()`                   |
| --------------- | ------------------- | --------------------------------- |
| `/`             | `/`                 | `{}`                              |
| `/user/:id`     | `/user/42`          | `{ id: "42" }`                    |
| `/:org/:repo`   | `/ilha/router`      | `{ org: "ilha", repo: "router" }` |
| `/docs/**:slug` | `/docs/guide/intro` | `{ slug: "guide/intro" }`         |
| `/**`           | anything            | `{}`                              |

Static segments always take priority over `:param` segments.

### `.mount(target, options?)` — browser only

Mounts the router into a DOM element or CSS selector. Sets up `popstate` listening and intercepts internal `<a>` clicks automatically. Returns an `unmount` function.

| Option     | Type                     | Default     | Description                                             |
| ---------- | ------------------------ | ----------- | ------------------------------------------------------- |
| `hydrate`  | `boolean`                | `false`     | Preserve SSR DOM, don't wipe on first mount             |
| `registry` | `Record<string, Island>` | `undefined` | Island registry for interactive hydration on navigation |

### `.render(url)` — server only

Returns a synchronous HTML string for the matched route. Renders `<div data-router-empty></div>` when no route matches.

### `.renderHydratable(url, registry, options?, request?)` — server only

Async variant of `.render()` that outputs HTML with `data-ilha` hydration markers and serialized loader data. Use this in your SSR handler for the full hydration pipeline.

### `.renderResponse(url, registry, options?, request?)` — server only

Structured-envelope variant of `.renderHydratable()` that returns a discriminated union instead of a raw string, letting your server emit proper HTTP status codes:

```ts
const res = await router()
  .route("/protected", protectedPage, authLoader)
  .renderResponse("/protected", registry);

if (res.kind === "redirect")
  return Response.redirect(res.to, res.status);
if (res.kind === "error")
  return new Response(res.html, { status: res.status });
return new Response(res.html, {
  headers: { "content-type": "text/html" },
});
```

| `kind`       | Fields                      | When                             |
| ------------ | --------------------------- | -------------------------------- |
| `"html"`     | `html`, `status?`           | Normal render                    |
| `"redirect"` | `to`, `status`              | Loader called `redirect()`       |
| `"error"`    | `status`, `message`, `html` | Loader called `error()` or threw |

### `.hydrate(registry, options?)` — browser only

Convenience method combining `.prime()`, `ilha.mount()`, and `.mount()` in one call. This is the recommended client entry point.

```ts
pageRouter.hydrate(registry);

// With options:
pageRouter.hydrate(registry, {
  root: document.getElementById("root"),
  target: "#app",
});
```

## Loaders

A loader is a data-fetching function that runs before a page renders. Its return value is passed as input props to the island.

```ts
import { loader, redirect, error } from "@ilha/router";

export const load = loader(
  async ({ params, request, signal }) => {
    const session = await getSession(request);
    if (!session) redirect("/login");

    const user = await fetchUser(params.id, { signal });
    if (!user) error(404, "User not found");

    return { user };
  },
);
```

The loader receives a `LoaderContext`:

```ts
{
  params: Record<string, string>; // matched URL params
  request: Request; // the incoming request
  url: URL; // parsed URL
  signal: AbortSignal; // abort on superseded navigation
}
```

On the server, loaders run inside `.renderHydratable()`. You can also call **`.runLoader(url, request?)`** on the builder to execute a loader without rendering HTML. On the client, navigations fetch loader data from the `/__ilha/loader` endpoint before mounting the next island.

### `redirect(to, status?)`

Throws a redirect sentinel inside a loader. The router catches it and either issues an HTTP redirect on the server or calls `navigate()` on the client.

```ts
redirect("/login"); // 302 by default
redirect("/moved", 301); // permanent
```

### `error(status, message)`

Throws a loader error sentinel. On the server, use `.renderResponse()` to intercept it and emit a proper HTTP status code.

```ts
error(404, "Not found");
error(403, "Forbidden");
```

### `composeLoaders(loaders)`

Merges multiple loaders into one. All run concurrently via `Promise.all`. Later loaders win on key collision — the page loader overrides layout loaders for the same key.

```ts
const combined = composeLoaders([layoutLoader, pageLoader]);
// → { user: …, post: … }
```

Used internally by the pages plugin. Also available for manual composition.

## Navigation helpers

### `navigate(to, options?)`

Programmatically navigate to a path. Updates the URL, history stack, and all reactive signals. Duplicate navigations are no-ops.

```ts
import { navigate } from "@ilha/router";

navigate("/about");
navigate("/about", { replace: true }); // replaces instead of pushing
```

### `prefetch(pathWithSearch)`

Prefetches loader data for a path in the background. The result is cached and consumed on the next navigation, making the transition feel instant.

```ts
prefetch("/user/42");
prefetch("/dashboard?tab=overview");
```

`RouterLink` calls this automatically on `mouseenter` for links with the `data-prefetch` attribute.

## Hash mode

By default the router uses the HTML5 History API (`location.pathname`). That needs a server or host that serves your SPA shell for every URL. When that is not possible — `file://`, Electron, or hosts without SPA fallback — use **hash mode** so the route lives in `location.hash`:

```ts
import { setHistoryMode, router } from "@ilha/router";

setHistoryMode("hash"); // once, before .mount() / .hydrate() / prime()

router()
  .route("/", HomePage)
  .route("/about", AboutPage)
  .mount("#app");
```

URLs look like `index.html#/about` or `index.html#/user/42?tab=1`. `navigate("/about")` still takes a logical path (no `#` prefix). `<RouterLink>` emits `href="#/…"` in hash mode.

**SSR + hydration is not supported in hash mode** — the hash is never sent to the server. Use plain `.mount("#app")` without `{ hydrate: true }`. Loaders can still run on the client via `/__ilha/loader` or `runLoader()`.

History mode is **process-global** (`navigate`, `RouterLink`, and `prefetch` share it). Set it once at app entry.

## Route context

These signals reflect the current route and are safe to read inside any island on both server and client.

### `useRoute()`

Returns reactive accessors for the current route state as a convenience object:

```tsx
import ilha from "ilha";
import { useRoute } from "@ilha/router";

const MyPage = ilha.render(() => {
  const { path, params, search, hash } = useRoute();
  return <p>User: {params().id}</p>;
});
```

### `routePath` · `routeParams` · `routeSearch` · `routeHash`

The underlying context signals for direct access outside of islands:

```ts
import { routePath, routeParams } from "@ilha/router";

routePath(); // → "/user/42"
routeParams(); // → { id: "42" }
```

### `isActive(pattern)`

Returns `true` if the current path matches a registered pattern:

```ts
import { isActive } from "@ilha/router";

isActive("/about"); // → true / false
isActive("/user/:id"); // → true when on any /user/* path
```

## Built-in islands

### `RouterView`

The outlet island rendered by `.mount()` and `.render()`. Wraps the active page island in `<div data-router-view>`. Renders `<div data-router-empty></div>` when no route matches.

### `RouterLink`

A declarative link island. Calls `navigate()` on click and prefetches loader data on `mouseenter`.

```ts
RouterLink.toString({ href: "/about", label: "About" });
// → '<a data-link data-prefetch href="/about">About</a>'
```

Opt a specific link out of prefetching with `data-prefetch="false"`.

## File-system routing

`@ilha/router` ships a **pages plugin** (built on [unplugin](https://unplugin.unjs.io/)) that scans `src/pages/`, resolves layout and error boundary chains, and generates a ready-to-use router with zero manual route registration. The same plugin works across bundlers — pick the entry that matches your toolchain:

| Bundler  | Import                  |
| -------- | ----------------------- |
| Vite     | `@ilha/router/vite`     |
| Rspack   | `@ilha/router/rspack`   |
| Rolldown | `@ilha/router/rolldown` |

Vite 8+ may use Rolldown under the hood; use `@ilha/router/vite` in `vite.config.ts` either way. Use `@ilha/router/rolldown` when you configure [Rolldown](https://rolldown.rs/) directly (for example with [tsdown](https://tsdown.dev/)).

### Setup

**Vite**

```ts
// vite.config.ts
import { defineConfig } from "vite";
import { pages } from "@ilha/router/vite";

export default defineConfig({
  plugins: [pages()],
});
```

**Rspack**

```ts
// rspack.config.ts
import { rspack } from "@rspack/core";
import { pages } from "@ilha/router/rspack";

export default {
  plugins: [
    pages(),
    new rspack.HtmlRspackPlugin({
      template: "./index.html",
    }),
  ],
  resolve: {
    extensions: ["...", ".ts", ".tsx"],
  },
  module: {
    rules: [
      {
        test: /\.ts$/,
        exclude: /node_modules/,
        loader: "builtin:swc-loader",
        options: {
          jsc: {
            parser: {
              syntax: "typescript",
            },
          },
        },
        type: "javascript/auto",
      },
      {
        test: /\.tsx$/,
        exclude: /node_modules/,
        loader: "builtin:swc-loader",
        options: {
          jsc: {
            parser: {
              syntax: "typescript",
              tsx: true,
            },
            transform: {
              react: {
                runtime: "automatic",
                importSource: "ilha",
                throwIfNamespace: false,
              },
            },
          },
        },
        type: "javascript/auto",
      },
    ],
  },
};
```

**Rolldown** (tsdown, Rolldown CLI, or other Rolldown-based tools)

```ts
// tsdown.config.ts
import { pages } from "@ilha/router/rolldown";
import { defineConfig } from "tsdown";

export default defineConfig({
  entry: ["src/client.ts"],
  plugins: [pages()],
});
```

Add `.ilha/` to `.gitignore`.

### Directory structure

```
src/pages/
  +layout.tsx             ← root layout (wraps all pages)
  +error.tsx              ← root error boundary
  index.tsx               → /
  about.tsx               → /about
  (auth)/                 ← route group — invisible in the URL
    +layout.tsx           ← layout scoped to (auth) pages only
    sign-in.tsx           → /sign-in
    sign-up.tsx           → /sign-up
  user/
    +layout.tsx           ← nested layout (wraps user/* only)
    +error.tsx            ← nested error boundary
    [id].tsx              → /user/:id
    [id]/
      settings.tsx        → /user/:id/settings
  [...slug].tsx            → /**:slug
```

### Filename → pattern mapping

| File                 | Pattern     |
| -------------------- | ----------- |
| `index.tsx`          | `/`         |
| `about.tsx`          | `/about`    |
| `[id].tsx`           | `/:id`      |
| `user/[id].tsx`      | `/user/:id` |
| `[...slug].tsx`      | `/**:slug`  |
| `(auth)/sign-in.tsx` | `/sign-in`  |

### Route groups

Folders wrapped in parentheses — `(name)` — organise files without contributing a URL segment. Use them for shared layouts, co-located pages, or logical grouping with no effect on the URL.

### Layouts

A `+layout.tsx` wraps every page in its directory and all subdirectories. Layouts compose inside-out — nearest layout is innermost. (`.ts` is also supported if you do not need JSX.)

```tsx
// src/pages/+layout.tsx
import { defineLayout } from "@ilha/router";
import ilha, { html } from "ilha";

export default defineLayout((Children) =>
  ilha.render(
    () => html`
      <nav>
        <a href="/">Home</a>
        <a href="/about">About</a>
      </nav>
      <main>${Children}</main>
    `,
  ),
);
```

### Page loaders

Export a `load` function from any page file. The pages plugin detects it automatically, composes it with any layout loaders, and wires it into the router at SSR time.

```tsx
// src/pages/user/[id].tsx
import { loader } from "@ilha/router";
import ilha from "ilha";

type User = { name: string };

export const load = loader(async ({ params }) => {
  const user = await fetchUser(params.id);
  return { user };
});

export default ilha
  .input<{ user: User }>()
  .render(({ input }) => <h1>{input.user.name}</h1>);
```

### Error boundaries

A `+error.tsx` catches rendering errors for pages in its directory. The nearest boundary wins. If it re-throws, the next outer boundary takes over. (`.ts` is also supported if you do not need JSX.)

```tsx
// src/pages/+error.tsx
import type { ErrorHandler } from "@ilha/router";
import ilha from "ilha";

export default ((err, route) =>
  ilha.render(() => (
    <div class="error">
      <h1>{err.status ?? 500}</h1>
      <p>{err.message}</p>
    </div>
  ))) satisfies ErrorHandler;
```

### Virtual modules

Use the **explicit** `/server` or `/client` suffix — they resolve to different generated files (raw imports for SSR vs `?client` imports for the browser bundle).

| Module              | Exports                  | Use for                                |
| ------------------- | ------------------------ | -------------------------------------- |
| `ilha:pages/server` | `pageRouter`, `registry` | SSR, prerender, Nitro handlers         |
| `ilha:pages/client` | `pageRouter`, `registry` | Browser hydration entry                |
| `ilha:loaders`      | —                        | Server-only side-effect: wires loaders |

For **`mode: "static"`** (MPA / pre-rendered HTML), hydrate per page with:

```ts
import { pageRouter, registry } from "ilha:pages/client";

pageRouter.hydrateStatic(registry);
```

### Plugin options

Options are the same for every bundler entry (`vite`, `rspack`, `rolldown`):

```ts
pages({
  dir: "src/pages", // default
  outDir: ".ilha", // default
  mode: "spa", // "spa" | "static" (default: "spa")
  interceptLinks: true, // spa only — false = full page loads on <a> clicks
});
```

- **`mode: "spa"`** — full route graph, SSR/hydration, client navigation.
- **`mode: "spa", interceptLinks: false`** — SSR/hydration, but internal links reload the document.
- **`mode: "static"`** — registry only on the client; no bundled route graph — use `hydrateStatic`.

For advanced use, import `ilhaPages` from any bundler entry and call `.vite()`, `.rspack()`, or `.rolldown()` on the shared factory.

## Route sorting

Routes are sorted automatically — no need to order files manually:

1. **Static** paths (`/about`) — highest priority
2. **Parameterised** paths (`/user/:id`)
3. **Wildcard** paths (`/**:slug`) — lowest priority

Within the same tier, longer segment counts and alphabetical order act as tiebreakers.

## SSR + hydration flow

```
server                              client
──────────────────────────────────  ────────────────────────────────────
renderHydratable(url, registry)     pageRouter.hydrate(registry)
  → loader runs, props serialized     → prime() — syncs route signals
  → island.hydratable(props, …)       → mount(registry) — hydrates islands
  → data-ilha-state snapshot          → mount(target, { hydrate: true })
  → data-ilha-props on island           → navigation handler activated
```

On navigation, the client fetches `/__ilha/loader?path=…`, receives loader data as JSON, and mounts the next island with hydration markers — no full page reload.

## TypeScript

Key exported types:

```ts
import type {
  LoaderContext, // { params, request, url, signal }
  LoaderFn, // the async function passed to loader()
  ErrorHandler, // (err, route) => Island — for +error.ts files
} from "@ilha/router";
```

## Related

| Topic                      | Guide                                                            |
| -------------------------- | ---------------------------------------------------------------- |
| Core `ilha` API            | [ilha](/guide/libraries/ilha)                                    |
| Shared state across routes | [Store](/guide/libraries/store)                                  |
| Form submit handlers       | [Store — Forms](/guide/libraries/store#forms) (`preventDefault`) |
| Route context signals      | [Signals](/guide/helpers/signals)                                |
| Hydratable SSR             | [Hydratable](/guide/island/hydratable)                           |
