---
title: File-system routing
description: Generate routes from src/pages/ with the pages plugin — layouts, error boundaries, page loaders, and virtual modules.
---

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

```text
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 twoslash
// src/pages/+layout.tsx
import { defineLayout } from "@ilha/router";
import { ilha } from "ilha";

export default defineLayout((Children) =>
  ilha(() => (
    <>
      <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 twoslash
// src/pages/user/[id].tsx
import { loader, type InferLoader } from "@ilha/router";
import { ilha } from "ilha";

async function fetchUser(id?: string, _signal?: AbortSignal) {
  return { name: "Ada" };
}
// ---cut---
export const load = loader(async ({ params, signal }) => {
  const user = await fetchUser(params.id, { signal });
  return { user };
});

export default ilha<InferLoader<typeof load>>(({ input }) => (
  <h1>{input.load.value.user.name}</h1>
));
```

See [Loaders](/guide/routing/loaders) for the full loader contract.

## Client loaders — `loader.client`

Export `load = loader.client(…)` to run a loader **in the browser** on client navigations, instead of fetching from the loader endpoint. Use it for data that is fetchable from the client anyway (public APIs, your app's own REST endpoints) — it saves a server round-trip per navigation and works on static hosts with no loader endpoint at all.

Two spellings, same behavior:

```tsx twoslash
// src/pages/dashboard.tsx
import { loader, type InferLoader } from "@ilha/router";
import { ilha } from "ilha";

type Stats = { views: number };
const Dashboard = (props: {
  stats: Stats;
  loading: boolean;
}) => <p>{props.loading ? "…" : props.stats.views}</p>;
// ---cut---
// sugar — a client loader under the server export name:
export const load = loader.client(async ({ signal }) => {
  const stats = await fetch("/api/stats", { signal }).then(
    (response): Promise<Stats> => response.json(),
  );
  return { stats };
});

export default ilha<InferLoader<typeof load>>(({ input }) => (
  <Dashboard
    stats={input.load.value.stats}
    loading={input.load.loading}
  />
));
```

### The `input.load` envelope

Client-loader results arrive in an envelope shaped like ilha's derived one:

```ts
input.load.value; // → { stats }
input.load.loading; // false once the loader has settled
input.load.error; // undefined when no error
```

Read it via property access (`input.load.value.stats`). Server `load` data uses the identical shape, so templates work unchanged across both.

Every loader result — server or client — reads through `input.load` with the same shape in all contexts.

- Client loaders are **bundled into the client** — never put secrets, database clients, or server-only imports in them. Keep those in `load`, which stays server-only.
- A page can export **both**: `load` runs during SSR (first paint), the client loader runs on client navigations instead of the endpoint fetch. Make them return the same shape, then infer the page input from either loader.
- Layout client loaders compose with the page's — the page wins on key collision, mirroring server loaders.
- With SSR + hydration, a client-loader-only page is server-rendered without its data; the router runs the loader right after hydration and re-renders the route with the loaded props.
- The loader receives a synthetic `Request` — rely on `url`, `params`, and `signal`, not cookies or headers.

## 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.)

The nearest boundary also renders **loader errors** — `error(status, message)` or an unexpected throw in `load`/`clientLoad` — on both the server (with the proper HTTP status) and client navigations. Manual routers get the same via `.errorBoundary(pattern, handler)`.

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

export default ((err, route) =>
  ilha(() => (
    <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, server 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
  frameGuard, // optional (request) => Response | void — gates /__ilha/frame
  loaderGuard, // optional — gates /__ilha/loader separately (falls back to frameGuard)
  trustedOrigins, // optional string[] — explicit origins for frame/loader origin checks
  csrf, // optional (request) => boolean — extra verifier for the frame POST
});
```

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

The `frameGuard`, `loaderGuard`, `trustedOrigins`, and `csrf` options apply in development; production uses the shared guard slots from `@ilha/router/server-island-registry` — see [Middleware and security](/guide/routing/middleware-and-security).
