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:
loadruns 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 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, 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 — usehydrateStatic.
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.