Skip to content

@ilha/router

A lightweight isomorphic router for ilha apps with file-system routing, loaders, and SPA navigation.

Updated View as Markdown

@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(), so any island can read the current route without extra wiring.

Install

npm i @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

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

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" },
  },
);
// 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" },
    },
  );
});
// 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.

router(options)

Option Type Default Description
mode "spa" | "static" "spa" "static" disables client navigation — hydrate with hydrateStatic()
interceptLinks boolean true Intercept internal <a> clicks for SPA navigation
notFound Island Custom 404 island rendered when no route matches (see RouterView)
allowExternalRedirects boolean false Allow loader redirect() to cross-origin URLs; blocked targets become a 500 error
loaderTimeout number Max loader runtime in ms — see Loaders
viewTransitions boolean false Wrap client view swaps in document.startViewTransition() when supported

.route(pattern, island, loader?)

Registers a route. Uses 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

While mounted, the router takes over scroll restoration (history.scrollRestoration = "manual"): pushes scroll to the top or to the #hash target, and back/forward restores the position saved for that history entry — in both directions. Opt out per navigation with navigate(to, { scroll: false }). Positions live in memory only; after a full reload the browser’s own restoration takes over.

.render(url) — server only

Returns a synchronous HTML string for the matched route. When no route matches, renders the notFound island if configured, otherwise <div data-router-empty></div>.

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

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.

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.

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 };
  },
);

Use InferLoader to derive an island’s input from its loader instead of repeating the return shape:

import { loader, type InferLoader } from "@ilha/router";
import ilha from "ilha";

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.user.name}</h1>
));

InferLoader<typeof load> awaits asynchronous loaders and preserves the exact returned fields. Prefer it whenever one loader defines the page input. Use an explicit merged type when layout loaders or other sources add fields that are not present in that loader’s return value.

The loader receives a LoaderContext:

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

A loader runs wherever the router runs. When the route was registered in the browser — a plain SPA via .route(pattern, island, loader), hash mode, Electron, file:// — client navigations execute the loader locally: no server or loader endpoint needed. Routes that only mark a server loader (the SSR-split pages build) fetch from the /__ilha/loader endpoint instead. Locally-executed loaders receive a synthetic Request (no cookies or server context) — rely on url, params, and signal; redirect()s are checked against the same cross-origin policy as on the server.

.clientLoader(pattern, loader) attaches a loader that runs in the browser on client navigations, instead of the endpoint fetch. The FS-routing codegen uses it for clientLoad exports; manual routers can call it directly. When a route has both, the client loader wins on client navigations and the server loader runs during SSR.

With router({ loaderTimeout }), the loader is raced against its abort signal — even a loader that ignores ctx.signal is cut off when the timeout elapses (or the incoming request aborts) and surfaces as a 504 loader error. Pass ctx.signal to your fetches anyway so the underlying work is actually cancelled, not just abandoned.

Loader ctx.head(...) contributions are applied on the server and on client navigations — document.title and managed meta/link tags stay in sync as users navigate.

Same-island navigations update in place. A navigation that changes only params or the query string (/t/a/t/b, ?page=1?page=2) re-runs the loader, then pushes the fresh data into the already-mounted island as reactive input — ilha’s fine-grained morph reconciles the DOM, so island state, focus, caret position, and scroll all survive; the router never tears the view down. Only when the matched island itself changes does the router unmount and mount fresh. This is what makes a filter bar built on persistQuery work: an input bound with bind:value writes to the URL, the loader re-runs, and the list updates — without the input ever losing focus mid-typing. invalidate() takes the same in-place path, so refreshing data after a mutation doesn’t blur a form.

If a loader fails, the error renders through the route’s +error boundary when one is registered (see Error boundaries) — on the server renderResponse returns the boundary’s HTML with the error status, and client navigations mount the boundary island in the outlet. Without a boundary, a minimal inline data-router-error element renders instead.

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.

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.

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.

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

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

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

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

navigate("/about");
navigate("/about", { replace: true }); // replaces instead of pushing
navigate("/about", { scroll: false }); // keep the current scroll position

Scroll resets to the top (or the URL’s hash target) when the navigation lands on a different island. A same-island navigation — a param or query-string change like ?page=2 — keeps the current scroll position, since it’s a data change, not a page change; pass an explicit hash to scroll to a target anyway.

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.

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

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

Prefetched entries are single-use and expire after ~30 seconds. If the navigation that would consume a prefetch is superseded mid-flight, the prefetched result is discarded rather than applied to the wrong view.

Reactive — true while a client navigation (loader fetch + view swap) is in flight. Read it inside any island render to drive a progress bar or spinner. Also available as useRoute().navigating.

import { navigating } from "@ilha/router";
import ilha from "ilha";

const Spinner = ilha(() =>
  navigating() ? <div class="bar" /> : null,
);

invalidate()

Re-runs the current route’s loader and re-renders the view with fresh data — call it after a mutation. Resolves when the view has updated. No-op on the server or when no router is mounted.

import { invalidate } from "@ilha/router";

await api.deleteItem(id);
await invalidate(); // current page refetches and re-renders

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:

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). &lt;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:

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

const MyPage = ilha(() => {
  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:

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

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

isActive(pattern, options?)

Returns true if the current path matches a registered pattern:

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

isActive("/about"); // → true on /about
isActive("/user/:id"); // → true on /user/42 (the matched route's pattern)
isActive("/docs", { exact: false }); // → true on /docs and /docs/anything

The default (exact) form compares against the matched route’s registered pattern — an island registered under several patterns reports whichever pattern actually matched the current path. Pass { exact: false } for a prefix match on the current path instead (useful for highlighting a nav section across nested pages).

Built-in islands

RouterView

The outlet island rendered by .mount() and .render(). Wraps the active page island in <div data-router-view>.

When no route matches: with router({ notFound }), the 404 island renders inside <div data-router-view data-router-not-found> — server renders return it with HTTP status 404 (via renderResponse), and in the browser it is mounted with a full island lifecycle (events and effects work) in both SPA and hydrate modes. Without notFound, the outlet renders <div data-router-empty></div>.

Server render methods use the notFound of the router instance they were called on, so concurrent routers can’t clobber each other; the browser helpers follow the last-mounted router.

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

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) 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";

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.user.name}</h1>
));

Client loaders — clientLoad

Export a clientLoad function 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.

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

export const clientLoad = loader(async ({ signal }) => {
  const stats = await fetch("/api/stats", { signal }).then(
    (response): Promise<Stats> => response.json(),
  );
  return { stats };
});

export default ilha<InferLoader<typeof clientLoad>>(
  ({ input }) => <Dashboard stats={input.stats} />,
);
  • clientLoad is bundled into the client — never put secrets, database clients, or server-only imports in it. Keep those in load, which stays server-only.
  • A page can export both: load runs during SSR (first paint), clientLoad runs on client navigations instead of the endpoint fetch. Make them return the same shape, then infer the page input from either loader.
  • Layout clientLoads compose with the page’s — the page wins on key collision, mirroring server loaders.
  • With SSR + hydration, a clientLoad-only page is server-rendered without its data; the router runs clientLoad right after hydration and re-renders the route with the loaded props.
  • clientLoad 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, 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:

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
});
  • 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:

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";
Topic Guide
Core ilha API ilha
Shared state across routes Store
Form submit handlers Store — Forms (preventDefault)
Route context signals Signals
Hydratable SSR Hydratable
Astro integration Astro
Navigation

Type to search…

↑↓ navigate↵ selectEsc close