Ilha Router

@ilha/router is a lightweight, isomorphic router for ilha apps. It runs on the server as a synchronous HTML renderer and in the browser with full signal-driven reactivity. It is a separate package — not bundled with ilha — so you only pay for it when you need multi-page routing.

How it relates to ilha

Every route in @ilha/router is just an ilha island. The router maps URL patterns to islands, runs their loaders, and manages which one is rendered or mounted at any given time. Route context signals (routePath, routeParams, etc.) are standard context() signals, so any island in your app can read the current route without any special wiring.

Install

npm
yarn
pnpm
bun
deno
npm install @ilha/router

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
import { pageRouter } from "ilha:pages";
import { registry } from "ilha:registry";
import "ilha:loaders"; // wires server-only loaders

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 } from "ilha:pages";
import { registry } from "ilha:registry";

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 for matching — the same engine as Nitro. First match wins.

PatternMatchesrouteParams()
//{}
/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.

OptionTypeDefaultDescription
hydratebooleanfalsePreserve SSR DOM, don't wipe on first mount
registryRecord<string, Island>undefinedIsland 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:

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" } });
kindFieldsWhen
"html"html, status?Normal render
"redirect"to, statusLoader called redirect()
"error"status, message, htmlLoader 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 };
});

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

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 Vite 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

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.


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 
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
from "ilha";
import {
function useRoute(): {
    path: {
        (): string;
        (value: string): void;
    };
    params: {
        (): Record<string, string>;
        (value: Record<string, string>): void;
    };
    search: {
        (): string;
        (value: string): void;
    };
    hash: {
        (): string;
        (value: string): void;
    };
}
useRoute
} from "@ilha/router";
const
const MyPage: Island<Record<string, unknown>, Record<string, never>>
MyPage
=
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, Record<string, never>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, Record<string, never>>
render
(() => {
const {
const path: {
    (): string;
    (value: string): void;
}
path
,
const params: {
    (): Record<string, string>;
    (value: Record<string, string>): void;
}
params
,
const search: {
    (): string;
    (value: string): void;
}
search
,
const hash: {
    (): string;
    (value: string): void;
}
hash
} =
function useRoute(): {
    path: {
        (): string;
        (value: string): void;
    };
    params: {
        (): Record<string, string>;
        (value: Record<string, string>): void;
    };
    search: {
        (): string;
        (value: string): void;
    };
    hash: {
        (): string;
        (value: string): void;
    };
}
useRoute
();
return `<p>User: ${
const params: () => Record<string, string> (+1 overload)
params
().
string
id
}</p>`;
});

routePath · routeParams · routeSearch · routeHash

The underlying context signals for direct access outside of islands:

import { 
const routePath: {
    (): string;
    (value: string): void;
}
routePath
,
const routeParams: {
    (): Record<string, string>;
    (value: Record<string, string>): void;
}
routeParams
} from "@ilha/router";
function routePath(): string (+1 overload)
routePath
(); // → "/user/42"
function routeParams(): Record<string, string> (+1 overload)
routeParams
(); // → { id: "42" }

isActive(pattern)

Returns true if the current path matches a registered pattern:

import { 
function isActive(pattern: string): boolean
isActive
} from "@ilha/router";
function isActive(pattern: string): boolean
isActive
("/about"); // → true / false
function isActive(pattern: string): boolean
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.

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 Vite plugin that scans src/pages/, resolves layout and error boundary chains, and generates a ready-to-use router with zero manual route registration.

Setup

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

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

Add .ilha/ to .gitignore.

Directory structure

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

Filename → pattern mapping

FilePattern
index.ts/
about.ts/about
[id].ts/:id
user/[id].ts/user/:id
[...slug].ts/**:slug
(auth)/sign-in.ts/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.ts wraps every page in its directory and all subdirectories. Layouts compose inside-out — nearest layout is innermost.

// src/pages/+layout.ts
import { 
function defineLayout(layout: LayoutHandler): LayoutHandler
defineLayout
} from "@ilha/router";
import
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
, {
const html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml
html
} from "ilha";
export default
function defineLayout(layout: LayoutHandler): LayoutHandler
defineLayout
((
children: Island<any, any>
children
) =>
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, Record<string, never>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, Record<string, never>>
render
(
() =>
const html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml
html
`
<nav> <a href="/">Home</a> <a href="/about">About</a> </nav> <main>${
children: Island<any, any>
children
}</main>
`, ), );

Page loaders

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

// src/pages/user/[id].ts
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.ts catches rendering errors for pages in its directory. The nearest boundary wins. If it re-throws, the next outer boundary takes over.

// src/pages/+error.ts
import type { 
type ErrorHandler = (error: AppError, route: RouteSnapshot) => Island<any, any>
ErrorHandler
} from "@ilha/router/vite";
import
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
from "ilha";
export default ((
err: AppError
err
,
route: RouteSnapshot
route
) =>
const ilha: IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>> & {
    html: (strings: TemplateStringsArray, ...values: unknown[]) => RawHtml;
    raw: (value: string) => RawHtml;
    mount: (registry: IslandRegistry, options?: MountOptions) => MountResult;
    from: <TInput, TStateMap extends Record<string, unknown>>(selector: string | Element, island: Island<TInput, TStateMap>, props?: Partial<TInput>) => (() => void) | null;
    context: <T>(key: string, initial: T) => ContextSignal<...>;
}
ilha
.
IlhaBuilder<Record<string, unknown>, Record<string, never>, Record<string, never>>.render(fn: (ctx: RenderContext<Record<string, unknown>, Record<string, never>, Record<string, never>>) => string | RawHtml): Island<Record<string, unknown>, Record<string, never>>
render
(
() => ` <div class="error"> <h1>${
err: AppError
err
.
AppError.status?: number | undefined
status
?? 500}</h1>
<p>${
err: AppError
err
.
AppError.message: string
message
}</p>
</div> `, )) satisfies
type ErrorHandler = (error: AppError, route: RouteSnapshot) => Island<any, any>
ErrorHandler
;

Virtual modules

The plugin exposes three virtual modules:

ModuleExportDescription
ilha:pagespageRouterRouterBuilder with all routes registered
ilha:registryregistryRecord<string, Island> for hydration
ilha:loadersSide-effect import that wires server loaders

Plugin options

function pages(options?: IlhaPagesOptions): Plugin<any>
pages
({
IlhaPagesOptions.dir?: string | undefined

Directory containing page files. Default: src/pages

dir
: "src/pages", // default
IlhaPagesOptions.generated?: string | undefined

Output path for the generated routes + registry file. Default: .ilha/routes.ts

generated
: ".ilha/routes.ts", // default
});

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