Skip to content
Ilha
Esc
navigateopen⌘Jpreview
On this page

File-system routing

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) 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 directly (for example with tsdown).

Setup

Vite

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

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

Rspack

// 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)

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

// 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.

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

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

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 errorserror(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).

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

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

pageRouter.hydrateStatic(registry);

Plugin options

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

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.

Was this page helpful?