@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/routeryarn add @ilha/routerpnpm add @ilha/routerbun add @ilha/routerImport 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.
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); // permanenterror(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
navigate("/about", { scroll: false }); // keep the current scroll positionScroll 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.
navigating()
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-rendersHash 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(() => {
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/anythingThe 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.
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 → /**:slugFilename → 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} />,
);clientLoadis bundled into the client — never put secrets, database clients, or server-only imports in it. Keep those inload, which stays server-only.- A page can export both:
loadruns during SSR (first paint),clientLoadruns 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 runsclientLoadright after hydration and re-renders the route with the loaded props. clientLoadreceives a syntheticRequest— rely onurl,params, andsignal, 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).
// 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 — 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 activatedOn 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 |
| Astro integration | Astro |