# Ilha > Ilha is a lightweight UI framework under 2,500 lines of code. Simple enough to fit in a single AI context window, powerful enough to build modern interfaces your way. # Ilha for Svelte Developers Source: https://ilha.build/blog/ilha-for-svelte-developers If you know Svelte, Ilha’s fine-grained reactivity should feel familiar. You write function components. You declare `atom()` values. Reads subscribe. Writes rerun the component and morph the DOM. This is not an argument that Ilha is better than Svelte. It is a map from familiar Svelte ideas to Ilha. ## What is Ilha? Ilha is a small isomorphic UI library. A component renders HTML on the server and activates in the browser. You do not turn the whole page into a client app by default. ```tsx twoslash import { atom } from "ilha"; export const Counter = () => { const count = atom(0); return ( ); }; ``` ## `$state` vs `atom()` Svelte reads and writes a variable. Ilha reads with `count()` and writes with `.set()` / `.update()`. In JSX, `{count}` subscribes. ```tsx twoslash import { atom } from "ilha"; export const Counter = () => { const count = atom(0); return ( ); }; ``` ## `$derived` vs `Atom.map` ```tsx twoslash import * as Atom from "effect/unstable/reactivity/Atom"; import { atom } from "ilha"; export const Totals = () => { const count = atom(0); const doubled = atom(Atom.map(count.atom, (n) => n * 2)); return

{doubled}

; }; ``` Async and streamed data use `when` / Stream views, not a derived accessor with `.loading`. ## `$effect` vs `watch()` ```tsx twoslash import { atom, watch } from "ilha"; const Title = () => { const title = atom("Hello"); watch(title, (value) => { document.title = value; }); return

{title}

; }; ``` For stream-driven ongoing logic, use `when` with `Atom.toStream` — see [Streams](/guide/ui/streams). For write-site DOM effects, handlers are fine. ## Props Props are ordinary function arguments. There is no `$props()` rune. ## Boundaries A nested function shares the parent component. `mount` / `renderToString` on a function makes it a root. ## SSR `await renderToString(Counter)` for HTML. `mount(host, Counter, { hydrate: true })` to attach events. | Svelte | Ilha | | ------------------- | ---------------------------------------------------- | | `$state` | `atom(value)` | | `$derived` | `atom(Atom.map(...))` or `atom(Atom.transform(...))` | | `$effect` | `watch()` or event handlers | | `bind:value` | `oninput` + `atom` | | `.svelte` component | function component | --- # Astro Source: https://ilha.build/guide/astro `@ilha/astro` is an [Astro](https://astro.build) integration for Ilha. You register it once, then import any component into an `.astro` page and hydrate it with a [client directive](https://docs.astro.build/en/reference/directives-reference/#client-directives). It is a separate package from `ilha`. Add it when you build with Astro. ## Install ```package-install @ilha/astro ``` `astro` and `ilha` are peer dependencies. ## Quick start ```ts twoslash // astro.config.mjs import { defineConfig } from "astro/config"; import ilha from "@ilha/astro"; export default defineConfig({ integrations: [ilha()], }); ``` Set `jsxImportSource` to `ilha` — see [Render and hydrate](/guide/ui/render): ```json { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "ilha" } } ``` ```tsx twoslash // src/islands/counter.tsx import { atom } from "ilha"; export const Counter = ({ start = 0 }: { start?: number }) => { const count = atom(start); return ( ); }; ``` ```astro --- import { Counter } from "../islands/counter"; --- ``` ## How it works | Phase | What happens | | --- | --- | | Server | The renderer calls `renderToString()`, which wraps output in a `[data-ilha]` host with a state snapshot. | | Client | The client entry finds that host inside Astro's `` and calls `mount(..., { hydrate: true })`. | ``` server client ────────────────────────────────── ──────────────────────────────────── .renderToStaticMarkup(component) @ilha/astro/client → renderToString(component) → find [data-ilha] inside → data-ilha-state snapshot → mount(host, component, { hydrate: true }) → astro:unmount → unmount() ``` ## Client directives | Directive | Behavior | | ---------------- | -------------------------------------------- | | `client:load` | Hydrate as soon as the page loads. | | `client:idle` | Hydrate when the browser is idle. | | `client:visible` | Hydrate when the island enters the viewport. | | `client:media` | Hydrate when a media query matches. | | `client:only` | Skip SSR. Mount fresh in the browser. | ```astro --- import { Counter } from "../islands/counter"; --- ``` When you mix JSX frameworks, pass `include` globs: ```ts ilha({ include: ["**/ilha/**"] }); ``` ## Import paths | Import path | Use it for | | -------------------- | ----------------------------------------------- | | `@ilha/astro` | Integration (default export / `getRenderer()`). | | `@ilha/astro/client` | Client hydration entry (registered by Astro). | | `@ilha/astro/server` | Server renderer entry (registered by Astro). | ## Notes - Ilha components do not support Astro `` forwarding. Pass data through props. - `client:only` mounts with `mount(element, component)` against an empty host. - Peer ranges: `astro` `^7.2.9`, `ilha` `^0.14.0`. ## Related | Topic | Guide | | ---------------------- | -------------------------------------- | | Core `ilha` API | [ilha](/reference/ilha) | | SSR and hydrate | [Render and hydrate](/guide/ui/render) | | Multi-page SPA routing | [Router](/guide/routing/overview) | --- # Core concepts Source: https://ilha.build/guide/getting-started/core-concepts ilha is built around a small set of ideas: components, atoms, JSX, and isomorphic render. Once these click, the rest of the library feels straightforward. ## Component A component is a function that returns a view. It can be sync, async, or a generator. The same component can render to HTML and mount in the browser. It owns the atoms you declare while it runs. A nested plain function belongs to the parent component. Its `atom()` calls share that parent. ## Choose the smallest form | Form | Ownership | | ------------------------ | --------------------------------------- | | `const View = () => JSX` | Parent component owns atoms and cleanup | | `mount(el, View)` | `View` is the root fiber | | `function*` / `async` | Same root, with `yield*` / `await` | ```tsx twoslash import { atom } from "ilha"; const Badge = (props: Record) => ( {String(props.label)} ); const Label = (props: Record) => { const text = atom(String(props.label)); return ; }; ``` Read an atom with `text()` when you need the current value in JS. In JSX, `{text}` subscribes the render. ## Isomorphic components Use the same function two ways: - `await renderToString(component)` for HTML. - `mount(element, component)` for the browser. You do not split a component into server and client copies. ## Atoms An atom is a value you can read and update. When it changes, the component that read it reruns. ```tsx twoslash import { atom } from "ilha"; const Counter = () => { const count = atom(0); count(); // read count.set(5); // write count.update((n) => n + 1); return

{count}

; }; ``` Derived values use Effect's `Atom.map` or `Atom.transform`: ```tsx twoslash import * as Atom from "effect/unstable/reactivity/Atom"; import { atom } from "ilha"; const Cart = () => { const items = atom([{ n: 1 }, { n: 2 }]); const total = atom( Atom.map(items.atom, (list) => list.reduce((sum, item) => sum + item.n, 0)) ); return

{total}

; }; ``` Do not put JSX in an atom. Atoms hold data. Map arrays during render with `items().map(...)`, or paint a Stream for live server data. Use [`watch()`](/guide/ui/state#side-effects) for side effects on atom changes. ## JSX Prefer JSX. Interpolated values escape. Event props are lowercase (`onclick`, `onchange`). ```tsx twoslash import { atom } from "ilha"; const App = () => { const open = atom(false); return ( ); }; ``` Use a plain function for DOM events. You do not need a special action wrapper on the client. ## SSR and hydration `renderToString(component)` paints into a DOM, waits until idle, then returns HTML. By default it wraps the output in `
`. `mount(host, component, { hydrate: true })` restores atom snapshots from that host and attaches events. ## Mental model 1. A component runs and returns a view. 2. Atoms it reads subscribe that run. 3. A write schedules a rerun and morphs the DOM. 4. Streams and generators paint into holes inside the host. --- # Installation Source: https://ilha.build/guide/getting-started/installation Install ilha with your package manager. For a new project, start from a template so routing and mounting are already wired. ## Install ```package-install npm i ilha effect ``` `effect` is a peer dependency. Ilha atoms and streams sit on Effect. ## Templates | Template | Command | Sandbox | | --- | --- | --- | | [Vite SPA](https://github.com/ilhajs/ilha/tree/main/templates/vite-spa) | `npx giget@latest gh:ilhajs/ilha/templates/vite-spa` | [Open](https://stackblitz.com/github/ilhajs/ilha/tree/main/templates/vite-spa) | | [Oxide SPA](https://github.com/ilhajs/ilha/tree/main/templates/oxide-spa) | `npx giget@latest gh:ilhajs/ilha/templates/oxide-spa` | [Open](https://stackblitz.com/github/ilhajs/ilha/tree/main/templates/oxide-spa) | Vite SPA is a client app with file routes. Oxide SPA adds server pages, frames, and `action` from `oxidejs`. ## Requirements - ESM modules. - TypeScript for the best editor support. - A browser for `mount()`. - JSON-serializable atom snapshots when you hydrate. ## Import ```tsx twoslash import { atom, mount, renderToString } from "ilha"; ``` Named exports you will use most: ```tsx twoslash import { atom, when, mount, renderToString, h, Fragment } from "ilha"; ``` Custom elements come from `ilha/define`: ```tsx twoslash import { define } from "ilha/define"; ``` ## Minimal example ```tsx twoslash import { atom } from "ilha"; const Greeting = () =>

Hello, ilha!

; const Counter = () => { const count = atom(0); return ( ); }; ``` ## Server-side rendering ```tsx twoslash import { atom, renderToString } from "ilha"; const Counter = () => { const count = atom(0); return ( ); }; const html = await renderToString(Counter); ``` `renderToString` waits until in-flight work is idle, then serializes. Pass `{ timeout: ms }` to cut off early. ## Client-side mounting ```tsx twoslash import { mount } from "ilha"; const Counter = () =>

Count

; const root = document.getElementById("app"); if (root) { const unmount = mount(root, Counter); } ``` The returned function stops listeners and in-flight work. Call it when you remove the host. --- # Introduction Source: https://ilha.build/guide/getting-started/introduction import { example } from "./introduction.examples"; ilha is a tiny, isomorphic UI library for building reactive components. You render on the server and mount in the browser with signal-based updates. There is no virtual DOM and no compiler. Markup stays close to HTML. ## What ilha is A **component** is a function, async function, or generator that returns a view. The same component produces HTML through `renderToString()` and activates in the browser through `mount()`. You write a function component. You declare `atom()` values inside it. Reads during render subscribe that component; writes rerun it and morph the host. ## Why it exists Most UI stacks force you to choose between simplicity and interactivity. ilha keeps both close together: a small API, direct DOM updates, and one component for server and client. This makes ilha a good fit when you want: - Server-rendered markup. - Small interactive regions. - Explicit state. - No virtual DOM. ## How it feels to use A typical component reads like a small HTML-aware module: The same function can render to a string on the server and mount into the DOM on the client. ## Choose the smallest component form | Start with | Use it when | | --- | --- | | `const View = () => JSX` | Reusable markup inside a parent component | | `const View = function*` | You need `yield Stream.map(...)` or `yield* when(...)` | | `mount(el, View)` | The function is the root you hydrate or mount | Start plain: ```tsx twoslash const Status = () =>

Ready

; ``` Add local state with `atom()`: ```tsx twoslash import { atom } from "ilha"; const Status = () => { const message = atom("Ready"); return

{message}

; }; ``` A nested function without its own `mount()` / `renderToString()` belongs to the parent component. ## Core ideas ### Isomorphic rendering You produce HTML on the server and activate the same function in the browser. ### Fine-grained reactivity Atoms keep updates local. Changing one value does not rerender an entire application tree. ### Function components You write functions that return JSX. Generators `yield` streams and `yield*` Effects. Async functions can await before they return a view. ### JSX-first authoring Prefer JSX. It escapes interpolations. Use `h()` when you cannot use JSX. ## When to use ilha Use ilha when you want server HTML plus a few interactive regions, not a full client app by default. ## Basic mental model 1. Write a component that returns JSX. 2. Declare `atom()` values you read in the view. 3. Call `renderToString(component)` on the server. 4. Call `mount(element, component, { hydrate: true })` in the browser. --- # Without JSX Source: https://ilha.build/guide/recipes/h Prefer JSX. When a file cannot use the JSX runtime, call `h()`: ```ts twoslash import { atom, h } from "ilha"; const Counter = () => { const count = atom(0); return h( "button", { type: "button", onclick: () => count.update((n) => n + 1), }, "count: ", count ); }; ``` `h(type, props, ...children)` is the same factory JSX uses. There is no `html` tagged template in this API. ## Tagged templates If you want markup that looks like the old html tagged-template helper, look at [@knighted/jsx](https://github.com/knightedcodemonkey/jsx). It is a tagged-template JSX runtime. It is **not** built into Ilha. It is compatible with Ilha **only if** the tag emits `h()` vnodes (`$$ilha: 1`), not DOM nodes or `React.createElement`. The package defaults to React; override its `createElement` / `fragment` to `h` and `Fragment` from `ilha` if that option exists in the version you install. Use lowercase event props (`onclick`, not `onClick`). Ilha only binds all-lowercase `on*` keys. `class` and `className` both work. Prefer real JSX or `h()` for anything you ship. Treat `@knighted/jsx` as an optional authoring layer, not part of the Ilha contract. --- # Oxlint plugin Source: https://ilha.build/guide/recipes/oxlint TypeScript types your props and atoms. It cannot see call order, generator frames, or React-shaped event names. The Oxlint plugin flags those mistakes in the editor and in CI. The plugin ships as `@ilha/router/oxlint`. It is a JavaScript Oxlint plugin — no extra compiler. ## Install Install Oxlint and `@ilha/router` if the app does not already depend on the router. ```package-install npm i -D oxlint @ilha/router ``` ## Enable the plugin Point Oxlint at the export and turn on the rules you want. ```json { "jsPlugins": ["@ilha/router/oxlint"], "rules": { "oxlint-plugin-ilha/no-conditional-primitive": "error", "oxlint-plugin-ilha/no-primitive-outside-component": "error", "oxlint-plugin-ilha/no-instruction-outside-generator": "error", "oxlint-plugin-ilha/prefer-lowercase-events": "error", "oxlint-plugin-ilha/function-in-atom": "error" } } ``` Save this as `.oxlintrc.json` at the repo root. Run `oxlint` in your lint script. The plugin only treats `atom`, `watch`, and `when` as primitives when you import them from `"ilha"`. A router `action()` or a local binding does not match. ## Rules | Rule | Catches | | --- | --- | | `no-conditional-primitive` | `atom()` / `watch()` / `when()` inside `if` or a loop | | `no-primitive-outside-component` | Primitive calls at module top level — there is no fiber there | | `no-instruction-outside-generator` | `when` outside a generator, or without `yield*` | | `prefer-lowercase-events` | `onClick` instead of `onclick` | | `function-in-atom` | `atom(fn)` misread as a derived initializer instead of a stored function — use `atom(Atom.map(...))`, `atom(Atom.transform(...))`, or `atom.lazy(() => fn)` | --- # Server state with PubSub Source: https://ilha.build/guide/recipes/pubsub-state Server islands hold state in server memory. To push changes to every connected client, publish snapshots to an [Effect PubSub](https://effect.website/docs/v4/api/effect/PubSub) and expose one streaming action per module. ## The hub pattern ```tsx twoslash // src/lib/tasks.server.tsx import * as Effect from "effect/Effect"; import * as PubSub from "effect/PubSub"; import * as Stream from "effect/Stream"; import { action } from "oxidejs"; type Task = { id: string; text: string; completed: boolean }; const tasks: Task[] = []; const hub = Effect.runSync(PubSub.unbounded({ replay: 1 })); const notify = () => Effect.runSync(PubSub.publish(hub, [...tasks])); // The streaming transport: one async generator per module. export const getTasks = action(async function* () { yield* Stream.toAsyncIterable(Stream.fromPubSub(hub)); }); export const addTask = action(async (text: string) => { tasks.push({ id: crypto.randomUUID(), text, completed: false, }); notify(); }); ``` Three pieces: 1. **The hub** holds the change feed. `replay: 1` gives late subscribers the latest snapshot immediately — this is your initial render. 2. **Mutations publish** a fresh snapshot after each change. 3. **One streaming action** is the transport the router wires to the client. `replay: 1` is what makes SSR work: `renderToString` takes the first stream value, and the replay buffer delivers it instantly. ## Subscribe from an island ```tsx // src/li/tasks.server.tsx export const TaskList = async function TaskList() { return Stream.map( Stream.fromAsyncIterable(getTasks(), (error: unknown) => error instanceof Error ? error : new Error(String(error)) ), (list: Task[]) => (
    {list.map((task) => (
  • {task.text}
  • ))}
) ); }; ``` The scanner sees `Stream.fromAsyncIterable(getTasks(), …)` and wires `getTasks` as the client transport. On the browser, the proxy resumes the same generator over RPC — every publish repaints the island. ## Why the transport action stays `getTasks` looks redundant — the hub is right there. It is the boundary: the hub lives in server memory, and the browser can only reach it through an RPC generator. Keep one streaming export per server module and call it with `Stream.fromAsyncIterable` inside each island that needs live updates. ## Related | Topic | Guide | | --- | --- | | Server islands | [Server islands](/guide/routing/server-islands) | | Streams and generators | [Streams](/guide/ui/streams) — paint Effect `Stream` values; `when` for per-emission generators | | Triggering frames | [Server islands](/guide/routing/server-islands) | --- # Test components Source: https://ilha.build/guide/recipes/testing Test components with Bun's runner. Use `renderToString` for HTML assertions. Add happy-dom only when you mount into real nodes. ## Render to HTML `renderToString` builds HTML without a DOM. It is async — it waits until in-flight work is idle: ```tsx twoslash import { atom, renderToString } from "ilha"; import { expect, test } from "bun:test"; const Counter = () => { const count = atom(0); return ( ); }; test("renders the count", async () => { const html = await renderToString(Counter); expect(html).toContain(">0<"); }); ``` ## Mount and simulate events Mount and event tests need a DOM. Install happy-dom's global registrator and preload it when you mount into real nodes or touch browser-only APIs at import time: ```package-install bun add -d @happy-dom/global-registrator ``` ```toml # bunfig.toml [test] preload = "./happydom.ts" ``` ```ts twoslash // happydom.ts import { GlobalRegistrator } from "@happy-dom/global-registrator"; GlobalRegistrator.register(); ``` For behavior, mount into a detached element and dispatch events: ```tsx twoslash import { atom, mount } from "ilha"; import { expect, test } from "bun:test"; const Counter = () => { const count = atom(0); return ( ); }; test("clicking increments", async () => { const el = document.createElement("div"); document.body.append(el); const unmount = mount(el, Counter); await new Promise((r) => setTimeout(r, 5)); const button = el.querySelector("button")!; button.click(); await new Promise((r) => setTimeout(r, 5)); expect(button.textContent).toContain("1"); unmount(); el.remove(); }); ``` The short sleeps let microtasks paint. `unmount()` stops streams and listeners — call it before removing the host so a late event cannot paint into a detached tree. ## Hydration round-trip Render, inject, then hydrate to verify a snapshot restores. This path also needs the happy-dom preload above: ```tsx twoslash import { atom, mount, renderToString } from "ilha"; import { expect, test } from "bun:test"; const App = () =>

hello

; test("hydrates SSR markup", async () => { const html = await renderToString(App); const el = document.createElement("div"); el.innerHTML = html; // test host; markup is your own render output document.body.append(el); const unmount = mount(el, App, { hydrate: true }); await new Promise((r) => setTimeout(r, 5)); expect(el.textContent).toContain("hello"); unmount(); el.remove(); }); ``` A snapshot-order bug logs a `hydrate mismatch` warning and falls back to a full mount — assert on the warning in CI if hydration is load-bearing for you. ## Related | Topic | Guide | | --------------------- | ----------------------------------------------- | | Render and hydrate | [Render](/guide/ui/render) | | Streams in tests | [Streams](/guide/ui/streams) | | Server islands in dev | [Server islands](/guide/routing/server-islands) | --- # View transitions Source: https://ilha.build/guide/recipes/view-transitions You animate between UI states with the browser View Transition API. Ilha does not wrap it. Call `document.startViewTransition()` and update an atom inside the callback. ```tsx twoslash import { atom } from "ilha"; export default function Card() { const checked = atom(false); const toggle = () => { document.startViewTransition(() => { checked.update((value) => !value); }); }; return ( ); } ``` The callback must update state synchronously so the browser can snapshot both sides of the transition. --- # Showcase Source: https://ilha.build/guide/resources/showcase export const SHOWCASE = [ { title: "Ilha Website", image: "/showcase-ilha.jpg", link: "https://ilha.build", }, { title: "Areia UI", image: "/showcase-areia.jpg", link: "https://areia.ilha.build", }, { title: "thojensen.com", image: "/showcase-thojensen.jpg", link: "https://thojensen.com", }, ];
{SHOWCASE.map((item) => ( {item.title}
{item.title}
))}
--- # Deployment Source: https://ilha.build/guide/routing/deployment How you deploy depends on whether your app needs SSR/server islands or renders to static HTML. ## SPA + SSR (server-rendered) For file-system routing with [oxidejs](https://npmjs.com/package/oxidejs), the SSR middleware serves the internal frame endpoint in production — the route-graph imports are implicit: ```ts twoslash // vite.config.ts import pages from "@ilha/router/vite"; import oxide from "oxidejs/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [oxide({ middleware: ["@ilha/router/ssr"] }), pages()], }); ``` The `@ilha/router/ssr` middleware serves `POST /__ilha/frame` for server-island and server-page re-renders. Any host that runs an oxidejs fetch handler serves it. See [Server islands](/guide/routing/server-islands) and [Middleware and security](/guide/routing/middleware-and-security). If you host the router in your own fetch handler instead, use the manual render methods — `render()`, `renderResponse()`, or `respond()` — described in [Server islands](/guide/routing/server-islands). ## Registering guards in production Deny-by-default means `/__ilha/frame` returns `403` until you install a guard. Call the guard once at server startup, before handling requests: ```ts twoslash import { setFrameGuard } from "@ilha/router/ssr"; function isSignedIn(_request: Request) { return true; } // ---cut--- setFrameGuard((request) => isSignedIn(request) ? undefined : new Response("Unauthorized", { status: 401 }) ); ``` See [Middleware and security](/guide/routing/middleware-and-security) for the full guard, origin, and CSRF model. ## Static prerendering (MPA) When you do not need runtime SSR or server islands, set `mode: "static"` in the plugin options. The generated client module then exports a `registry` of pages without a route graph. Prerender each route's HTML at build time (for example with `renderResponse`), and mount the matching page from the registry: ```ts import { mount } from "ilha"; import { registry } from "ilha:pages/client"; const host = document.querySelector("#app")!; mount(host, registry["about"]); ``` Hash mode (`setHistoryMode("hash")`) is another static-host option when you cannot serve an SPA fallback for every URL. SSR + hydration is not supported in hash mode. ## Related | Topic | Guide | | --- | --- | | Guards in production | [Middleware and security](/guide/routing/middleware-and-security) | | Error handling | [Error boundaries](/guide/routing/error-boundaries) | | Plugin `mode` and virtual modules | [File-system routing](/guide/routing/file-system-routing) | --- # Error boundaries Source: https://ilha.build/guide/routing/error-boundaries A render failure in a page or layout does not take down the app. The nearest error boundary catches it and renders a fallback view. ## Throw from a page Throw `RouteError` with `error()` (or `redirect()` for navigation): ```tsx twoslash import { error } from "@ilha/router"; export default async function User({ params }: { params: { id: string } }) { const user = await findUser(params.id); if (!user) error(404, "No such user"); return

{user.name}

; } async function findUser(_id: string) { return null as null | { name: string }; } ``` `error(status, message)` throws a `RouteError`; `redirect(to, status?)` throws a `Redirect` that the router converts into a client navigation. ## +error.tsx files Drop a `+error.tsx` next to a page (or in a parent folder) to catch failures for everything under that folder. The nearest boundary wins: ```text src/pages/ +error.tsx ← catches all routes user/ +error.tsx ← catches /user/* [id].server.tsx ``` ```tsx twoslash // src/pages/user/+error.tsx import type { AppError } from "@ilha/router"; import type { View } from "ilha"; export default function ErrorPage({ error, children, }: { error: AppError; children?: View; }) { return (

{error.status ?? 500}

{error.message}

{children}
); } ``` Boundary components receive the `error` and the failed route as `children`. ## errorBoundary() on the builder With a hand-built router, register a boundary per pattern: ```tsx twoslash import { router } from "@ilha/router"; import { h } from "ilha"; const UserPage = async () => { throw new Error("boom"); }; // ---cut--- router() .route("/user/:id", UserPage) .errorBoundary("/user/:id", (err) => h("p", null, `Oops: ${err.message}`)); ``` The handler receives an `AppError` (`message`, `status?`) and a route snapshot, and returns a view or a component. ## wrapError() Wrap a single page when you want the fallback inline instead of a boundary file: ```tsx twoslash import { error, wrapError } from "@ilha/router"; import { h } from "ilha"; const UserPage = async () => { error(418, "short and stout"); }; // ---cut--- export default wrapError(({ message }) => h("p", null, message), UserPage); ``` `wrapError` catches everything the page throws — `RouteError` or any exception — and calls your handler with `{ message, status? }`. ## Related | Topic | Guide | | --- | --- | | Throwing redirects | [Routes and navigation](/guide/routing/routes-and-navigation) | | Server pages and frames | [Server islands](/guide/routing/server-islands) | | Hardening and status codes | [Middleware and security](/guide/routing/middleware-and-security) | --- # File-system routing Source: https://ilha.build/guide/routing/file-system-routing The pages plugin scans `src/pages/`, wraps layouts, and generates a router. Pick the entry for your toolchain: | Tool | Import | | ------- | ---------------------- | | Vite | `@ilha/router/vite` | | Rsbuild | `@ilha/router/rsbuild` | ## Setup ```ts twoslash import pages from "@ilha/router/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [pages()], }); ``` ## File names | File | URL | | --------------------- | -------------- | | `pages/index.tsx` | `/` | | `pages/about.tsx` | `/about` | | `pages/user/[id].tsx` | `/user/:id` | | `pages/[...slug].tsx` | `/**:slug` | | `pages/+layout.tsx` | wraps children | | `pages/+error.tsx` | error boundary | `foo.server.tsx` uses the same URL as `foo.tsx` and renders on the server. See [Server islands](/guide/routing/server-islands). ## Page module Export a default component: ```tsx import { head } from "@ilha/router"; export default function Home() { head({ title: "Home" }); return

Home

; } ``` ## Layout ```tsx twoslash import type { View } from "ilha"; export default function Layout({ children }: { children?: View }) { return (
{children}
); } ``` ## Client entry ```ts import { pageRouter } from "ilha:pages/client"; pageRouter.mount("#app"); ``` Hydrate when the host already has SSR markup: ```ts pageRouter.mount("#app", { hydrate: true }); ``` --- # Middleware and security Source: https://ilha.build/guide/routing/middleware-and-security `@ilha/router` hardens the server-island endpoint by default. This page is the full guard and origin model behind [Server islands](/guide/routing/server-islands). ## Security headers Both `respond()` and the low-level `httpResponse()` emit sensible security headers by default: | Header | Value | | ------------------------ | ------------- | | `Content-Type` | `text/html` | | `X-Content-Type-Options` | `nosniff` | | `Referrer-Policy` | `no-referrer` | | `Cache-Control` | `no-store` | A header is added only when the caller has not already set it, so you can override any default by passing `headers`. ```tsx twoslash import { httpResponse } from "@ilha/router"; const html = "

Hi

"; const nonce = "abc123"; // ---cut--- // Default: nosniff, no-referrer, no-store (see table above). const res = httpResponse("

Hi

"); // Conservative default CSP via a nonce — pass the same nonce to