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(), so any island can read the current route without extra wiring.
Install
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" },
},
);
SSR + hydration (recommended)
// 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.
.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 |
.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" },
});
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 };
},
);
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. 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 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.
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.
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). <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.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:
import { routePath, routeParams } from "@ilha/router";
routePath(); // → "/user/42"
routeParams(); // → { id: "42" }
isActive(pattern)
Returns true if the current path matches a registered pattern:
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.
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, { 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.
// 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.)
// 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:
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 — usehydrateStatic.
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:
- Static paths (
/about) — highest priority - Parameterised paths (
/user/:id) - 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";
Related
| Topic | Guide |
|---|---|
Core ilha API | ilha |
| Shared state across routes | Store |
| Form submit handlers | Store — Forms (preventDefault) |
| Route context signals | Signals |
| Hydratable SSR | Hydratable |