@ilha/router 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@ilha/router`
2
2
 
3
- A lightweight, isomorphic router for [Ilha](https://github.com/ilhajs/ilha) islands. Runs in the browser with full reactivity and on the server as a synchronous HTML string renderer. Pairs natively with [Nitro](https://nitro.build/) and includes a Vite plugin for file-system based routing.
3
+ A lightweight, isomorphic router for [Ilha](https://github.com/ilhajs/ilha) islands. Runs in the browser with full reactivity and on the server as a synchronous HTML string renderer. Pairs natively with the file-system routing Vite plugin for zero-config page management.
4
4
 
5
5
  ---
6
6
 
@@ -85,10 +85,22 @@ Returns a `RouterBuilder`.
85
85
 
86
86
  ---
87
87
 
88
- #### `.route(pattern, island)`
88
+ #### `.route(pattern, island, loader?)`
89
89
 
90
90
  Registers a route. Patterns are matched in **declaration order** — first match wins. Uses [rou3](https://github.com/h3js/rou3) for matching, the same engine as Nitro.
91
91
 
92
+ The optional `loader` is a data-fetching function that runs before the page renders. Its return value is passed as input props to the island. On the client, loaders are fetched via the `/__ilha/loader` endpoint on navigation.
93
+
94
+ ```ts
95
+ import { loader } from "@ilha/router";
96
+
97
+ const userLoader = loader(async ({ params }) => {
98
+ return { user: await fetchUser(params.id) };
99
+ });
100
+
101
+ router().route("/user/:id", userPage, userLoader).mount("#app");
102
+ ```
103
+
92
104
  | Pattern | Matches | `routeParams()` |
93
105
  | --------------- | ------------------- | --------------------------------- |
94
106
  | `/` | `/` | `{}` |
@@ -130,10 +142,10 @@ No-op with a console warning when called outside a browser environment.
130
142
 
131
143
  #### `.render(url)` — server / SSR
132
144
 
133
- Resolves the given URL against the route registry and returns a synchronous HTML string. Accepts a path string, full URL string, or `URL` object. Populates all route signals identically to the browser. Percent-encoded params are decoded automatically.
145
+ Resolves the given URL against the route registry and returns a synchronous HTML string. Accepts a path string, full URL string, or `URL` object. Populates all route signals identically to the browser.
134
146
 
135
147
  ```ts
136
- const html = router().route("/", homePage).route("/**", notFound).render("/");
148
+ const html = router().route("/", HomePage).route("/**", notFound).render("/");
137
149
  // → '<div data-router-view><p>home</p></div>'
138
150
  ```
139
151
 
@@ -141,13 +153,13 @@ Renders `<div data-router-empty></div>` when no route matches.
141
153
 
142
154
  ---
143
155
 
144
- #### `.renderHydratable(url, registry, options?)` — server / SSR
156
+ #### `.renderHydratable(url, registry, options?, request?)` — server / SSR
145
157
 
146
- Async variant of `.render()` that outputs HTML with `data-ilha` hydration markers so the client can rehydrate without a full re-render.
158
+ Async variant of `.render()` that outputs HTML with `data-ilha` hydration markers so the client can rehydrate without a full re-render. If a loader is registered for the matched route, it runs first and its return value is serialized into `data-ilha-props`.
147
159
 
148
160
  ```ts
149
- const html = await router().route("/", homePage).renderHydratable("/", registry);
150
- // → '<div data-router-view><div data-ilha="home">…</div></div>'
161
+ const html = await router().route("/", HomePage).renderHydratable("/", registry);
162
+ // → '<div data-router-view><div data-ilha="Home">…</div></div>'
151
163
  ```
152
164
 
153
165
  If the active island is not found in the registry, falls back to plain SSR and emits a `console.warn`.
@@ -160,6 +172,53 @@ If the active island is not found in the registry, falls back to plain SSR and e
160
172
 
161
173
  ---
162
174
 
175
+ #### `.renderResponse(url, registry, options?, request?)` — server / SSR
176
+
177
+ Structured-envelope variant of `.renderHydratable()`. Returns a `RenderResponse` discriminated union instead of a raw HTML string, so the host server can emit proper HTTP status codes for redirects and loader errors.
178
+
179
+ ```ts
180
+ const res = await router()
181
+ .route("/protected", protectedPage, authLoader)
182
+ .renderResponse("/protected", registry);
183
+
184
+ if (res.kind === "redirect") {
185
+ return Response.redirect(res.to, res.status);
186
+ }
187
+ if (res.kind === "error") {
188
+ return new Response(res.html, { status: res.status });
189
+ }
190
+ return new Response(res.html, { headers: { "content-type": "text/html" } });
191
+ ```
192
+
193
+ | `kind` | Fields | When |
194
+ | ------------ | --------------------------------------------------- | ------------------------------------------ |
195
+ | `"html"` | `html: string`, `status?: number` | Normal render; `status` is 404 if no match |
196
+ | `"redirect"` | `to: string`, `status: number` | Loader called `redirect()` |
197
+ | `"error"` | `status: number`, `message: string`, `html: string` | Loader called `error()` or threw |
198
+
199
+ ---
200
+
201
+ #### `.runLoader(url, request?)` — server / SSR
202
+
203
+ Runs the loader chain for the matched route without rendering any HTML. Returns a discriminated union result. Used by the `/__ilha/loader` endpoint the Vite plugin exposes for client-side navigation.
204
+
205
+ ```ts
206
+ const result = await router().route("/user/:id", userPage, userLoader).runLoader("/user/42");
207
+
208
+ if (result.kind === "data") {
209
+ console.log(result.data); // → { user: { id: "42" } }
210
+ }
211
+ ```
212
+
213
+ | `kind` | Fields | When |
214
+ | ------------- | ----------------------------------- | -------------------------------- |
215
+ | `"data"` | `data: Record<string, unknown>` | Loader succeeded (or no loader) |
216
+ | `"redirect"` | `to: string`, `status: number` | Loader called `redirect()` |
217
+ | `"error"` | `status: number`, `message: string` | Loader called `error()` or threw |
218
+ | `"not-found"` | — | No route matched the URL |
219
+
220
+ ---
221
+
163
222
  #### `.prime()` — browser only
164
223
 
165
224
  Primes route context signals from the current `window.location` **before** `ilha.mount()` runs. This prevents a signal mismatch that would destroy hydrated bindings.
@@ -180,7 +239,7 @@ pageRouter.mount("#app", { hydrate: true, registry });
180
239
 
181
240
  #### `.hydrate(registry, options?)` — browser only
182
241
 
183
- Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call. Use this as the recommended client entry point.
242
+ Convenience method that combines `.prime()`, `ilha.mount()`, and `.mount()` into a single call. **This is the recommended client entry point.**
184
243
 
185
244
  ```ts
186
245
  pageRouter.hydrate(registry);
@@ -196,6 +255,16 @@ Returns an `unmount` function that tears down all listeners and hydrated islands
196
255
 
197
256
  ---
198
257
 
258
+ #### `.attachLoader(pattern, loader)` — runtime
259
+
260
+ Attaches or replaces a loader on an already-registered route pattern. No-op if the pattern was never registered via `.route()`. Used by the `ilha:loaders` virtual module to wire server-only loaders onto the client-safe `pageRouter` at SSR time.
261
+
262
+ ```ts
263
+ router().route("/user/:id", userPage).attachLoader("/user/:id", serverLoader);
264
+ ```
265
+
266
+ ---
267
+
199
268
  ### `navigate(to, options?)`
200
269
 
201
270
  Programmatically navigate to a path. Updates the URL, history stack, and all reactive signals. Duplicate navigations (same URL) are no-ops.
@@ -223,6 +292,100 @@ prime();
223
292
 
224
293
  ---
225
294
 
295
+ ### `loader(fn)`
296
+
297
+ Identity function for declaring a typed data loader. Exists as a type anchor and as a marker the Vite plugin uses to detect exported loaders automatically. The loader receives a `LoaderContext` and must return or resolve to a plain object (serializable to JSON for client-side fetches).
298
+
299
+ ```ts
300
+ import { loader } from "@ilha/router";
301
+
302
+ export const load = loader(async ({ params, request, url, signal }) => {
303
+ const user = await fetchUser(params.id, { signal });
304
+ return { user };
305
+ });
306
+ ```
307
+
308
+ Inside a loader, call `redirect()` or `error()` to short-circuit rendering:
309
+
310
+ ```ts
311
+ import { loader, redirect, error } from "@ilha/router";
312
+
313
+ export const load = loader(async ({ params }) => {
314
+ const session = await getSession();
315
+ if (!session) redirect("/login", 302);
316
+ const post = await getPost(params.id);
317
+ if (!post) error(404, "Post not found");
318
+ return { post };
319
+ });
320
+ ```
321
+
322
+ Returns `fn` unchanged.
323
+
324
+ ---
325
+
326
+ ### `redirect(to, status?)`
327
+
328
+ Throws a `Redirect` sentinel that is caught by the loader execution pipeline. Always use inside a loader — do not catch it yourself.
329
+
330
+ ```ts
331
+ import { redirect } from "@ilha/router";
332
+
333
+ redirect("/login"); // 302 by default
334
+ redirect("/moved", 301); // permanent redirect
335
+ ```
336
+
337
+ ---
338
+
339
+ ### `error(status, message)`
340
+
341
+ Throws a `LoaderError` sentinel that is caught by the loader execution pipeline. The rendered output will be an inline error element; use `.renderResponse()` on the server to intercept loader errors before they reach the client.
342
+
343
+ ```ts
344
+ import { error } from "@ilha/router";
345
+
346
+ error(404, "Not found");
347
+ error(403, "Forbidden");
348
+ ```
349
+
350
+ ---
351
+
352
+ ### `composeLoaders(loaders)`
353
+
354
+ Merges multiple loaders into a single loader. All loaders run **concurrently** via `Promise.all`. Later loaders win on key collision — the page loader overrides a layout loader for the same key.
355
+
356
+ Used internally by the Vite plugin to compose layout loaders with the page loader. Also available for manual composition.
357
+
358
+ ```ts
359
+ import { composeLoaders, loader } from "@ilha/router";
360
+
361
+ const layoutLoader = loader(async () => ({ user: await getCurrentUser() }));
362
+ const pageLoader = loader(async ({ params }) => ({ post: await getPost(params.id) }));
363
+
364
+ const combined = composeLoaders([layoutLoader, pageLoader]);
365
+ // → { user: …, post: … }
366
+ ```
367
+
368
+ If any loader in the chain throws a `Redirect` or `LoaderError`, the composed loader re-throws it immediately.
369
+
370
+ ---
371
+
372
+ ### `prefetch(pathWithSearch)`
373
+
374
+ Prefetches the loader data for a given path by calling the `/__ilha/loader` endpoint in the background. The result is cached and consumed on the next navigation to that path, making the transition feel instant. Safe to call repeatedly — an in-flight request for the same path is reused until it resolves and is consumed, avoiding duplicate network requests.
375
+
376
+ ```ts
377
+ import { prefetch } from "@ilha/router";
378
+
379
+ prefetch("/user/42");
380
+ prefetch("/dashboard?tab=overview");
381
+ ```
382
+
383
+ No-op on the server, for paths with no registered loader, or for unmatched paths.
384
+
385
+ `RouterLink` automatically calls `prefetch()` on `mouseenter` for links that carry the `data-prefetch` attribute (set by default). You can opt a specific link out with `data-prefetch="false"`.
386
+
387
+ ---
388
+
226
389
  ### `useRoute()`
227
390
 
228
391
  Returns reactive signal accessors for the current route state. Safe to call inside any island render function on both client and server.
@@ -266,19 +429,25 @@ isActive("/user/:id"); // → true when on any /user/* path
266
429
 
267
430
  ---
268
431
 
269
- ### `enableLinkInterception(root?)`
432
+ ### `enableLinkInterception(root?, options?)`
270
433
 
271
434
  Attaches a delegated click listener to `root` (defaults to `document`) that intercepts `<a>` clicks and routes them client-side. Called automatically by `.mount()`.
272
435
 
273
- Skips links that are external, `target="_blank"`, anchor-only (`#hash`), or modified (`Ctrl`/`Meta`/`Shift`). Also skips events already handled (`e.defaultPrevented`).
436
+ Skips links that are external, `target="_blank"`, anchor-only (`#hash`), modified (`Ctrl`/`Meta`/`Shift`), or marked with `data-no-intercept`. Also skips events already handled (`e.defaultPrevented`).
274
437
 
275
438
  Returns a cleanup function.
276
439
 
277
440
  ```ts
278
- const stop = enableLinkInterception(myContainer);
441
+ const stop = enableLinkInterception(myContainer, { prefetch: true });
279
442
  stop(); // remove listener
280
443
  ```
281
444
 
445
+ **Options:**
446
+
447
+ | Option | Type | Default | Description |
448
+ | ---------- | --------- | ------- | ------------------------------------- |
449
+ | `prefetch` | `boolean` | `true` | Enable prefetch on `mouseenter` hover |
450
+
282
451
  No-op on the server.
283
452
 
284
453
  ---
@@ -298,13 +467,13 @@ RouterView.mount(el); // client
298
467
 
299
468
  ### `RouterLink`
300
469
 
301
- A declarative link island that calls `navigate()` on click.
470
+ A declarative link island that calls `navigate()` on click. Automatically prefetches loader data for the target path on `mouseenter` (opt out per-link with `data-prefetch="false"`).
302
471
 
303
472
  ```ts
304
473
  import { RouterLink } from "@ilha/router";
305
474
 
306
475
  RouterLink.toString({ href: "/about", label: "About" });
307
- // → '<a data-link href="/about">About</a>'
476
+ // → '<a data-link data-prefetch href="/about">About</a>'
308
477
  ```
309
478
 
310
479
  ---
@@ -321,9 +490,35 @@ const wrapped = wrapLayout(myLayout, myPage);
321
490
 
322
491
  ---
323
492
 
493
+ ### `defineLayout(fn)`
494
+
495
+ A typed helper that returns the layout function as-is. Use it instead of the `satisfies LayoutHandler` cast for a cleaner, import-light syntax.
496
+
497
+ ```ts
498
+ // src/pages/+layout.ts
499
+ import { defineLayout } from "@ilha/router";
500
+ import ilha, { html } from "ilha";
501
+
502
+ export default defineLayout((children) =>
503
+ ilha.render(
504
+ () => html`
505
+ <nav>
506
+ <a href="/">Home</a>
507
+ <a href="/about">About</a>
508
+ </nav>
509
+ <main>${children}</main>
510
+ `,
511
+ ),
512
+ );
513
+ ```
514
+
515
+ Equivalent to annotating with `satisfies LayoutHandler` but requires no explicit type import.
516
+
517
+ ---
518
+
324
519
  ### `wrapError(handler, page)`
325
520
 
326
- Wraps a page island with an error boundary. If the page throws during SSR (`.toString()`), the `handler` receives the error and current route snapshot and returns a fallback island. Also intercepts errors during `.mount()` for client-side resilience.
521
+ Wraps a page island with an error boundary. If the page throws during SSR (`.toString()`) or on the client during `.mount()`, the `handler` receives the error and current route snapshot and returns a fallback island. The nearest (innermost) `wrapError` boundary catches first. If the inner handler re-throws, the next outer boundary takes over.
327
522
 
328
523
  ```ts
329
524
  import { wrapError } from "@ilha/router";
@@ -331,7 +526,7 @@ import { wrapError } from "@ilha/router";
331
526
  const safe = wrapError(myErrorHandler, myPage);
332
527
  ```
333
528
 
334
- The nearest (innermost) `wrapError` boundary catches first. If the inner handler re-throws, the next outer boundary takes over.
529
+ > **Note:** Error boundaries wrap the _page island's render_, not the loader. Loader errors (thrown via `error()`) are surfaced through `.renderResponse()` — they do not currently route through `+error.ts` boundaries. Use `.renderResponse()` to handle loader errors at the HTTP layer.
335
530
 
336
531
  ---
337
532
 
@@ -351,9 +546,29 @@ interface AppError {
351
546
  stack?: string;
352
547
  }
353
548
 
549
+ interface LoaderContext {
550
+ params: Record<string, string>;
551
+ request: Request;
552
+ url: URL;
553
+ signal: AbortSignal;
554
+ }
555
+
556
+ type Loader<T> = (ctx: LoaderContext) => Promise<T> | T;
557
+
558
+ // Extract the return type of a loader
559
+ type InferLoader<L> = L extends Loader<infer T> ? Awaited<T> : never;
560
+
561
+ // Merge multiple loader return types — later loaders win on key collision
562
+ type MergeLoaders<Ls extends readonly Loader<any>[]> = /* … */;
563
+
354
564
  type LayoutHandler = (children: Island) => Island;
355
565
  type ErrorHandler = (error: AppError, route: RouteSnapshot) => Island;
356
566
 
567
+ type RenderResponse =
568
+ | { kind: "html"; html: string; status?: number }
569
+ | { kind: "redirect"; to: string; status: number }
570
+ | { kind: "error"; status: number; message: string; html: string };
571
+
357
572
  interface NavigateOptions {
358
573
  replace?: boolean;
359
574
  }
@@ -367,6 +582,21 @@ interface HydrateOptions {
367
582
  root?: Element;
368
583
  target?: string | Element;
369
584
  }
585
+
586
+ // Helper — returns fn as-is with LayoutHandler type enforced
587
+ function defineLayout(fn: LayoutHandler): LayoutHandler;
588
+
589
+ // Identity — type anchor and Vite plugin marker
590
+ function loader<T>(fn: Loader<T>): Loader<T>;
591
+
592
+ // Throws a Redirect sentinel — use inside loaders only
593
+ function redirect(to: string, status?: number): never;
594
+
595
+ // Throws a LoaderError sentinel — use inside loaders only
596
+ function error(status: number, message: string): never;
597
+
598
+ // Merges loaders — later loaders win on key collision
599
+ function composeLoaders<Ls extends readonly Loader<any>[]>(loaders: Ls): Loader<MergeLoaders<Ls>>;
370
600
  ```
371
601
 
372
602
  ---
@@ -392,32 +622,65 @@ Add `.ilha/` (or your custom `generated` path) to `.gitignore`.
392
622
 
393
623
  ```
394
624
  src/pages/
395
- +layout.ts ← root layout (wraps all pages)
396
- +error.ts ← root error boundary
397
- index.ts → /
398
- about.ts → /about
625
+ +layout.ts ← root layout (wraps all pages)
626
+ +error.ts ← root error boundary
627
+ index.ts → /
628
+ about.ts → /about
629
+ (auth)/ ← route group — transparent to the URL
630
+ +layout.ts ← layout scoped to (auth) pages only
631
+ sign-in.ts → /sign-in
632
+ sign-up.ts → /sign-up
633
+ (marketing)/ ← another route group
634
+ index.ts → /
399
635
  user/
400
- +layout.ts ← nested layout (wraps user/* only)
401
- +error.ts ← nested error boundary
402
- [id].ts → /user/:id
636
+ +layout.ts ← nested layout (wraps user/* only)
637
+ +error.ts ← nested error boundary
638
+ [id].ts → /user/:id
403
639
  [id]/
404
- settings.ts → /user/:id/settings
405
- [...slug].ts → /**:slug
640
+ settings.ts → /user/:id/settings
641
+ [...slug].ts → /**:slug
406
642
  ```
407
643
 
408
644
  ### Filename → pattern mapping
409
645
 
410
- | File | Pattern |
411
- | ----------------- | ------------- |
412
- | `index.ts` | `/` |
413
- | `about.ts` | `/about` |
414
- | `[id].ts` | `/:id` |
415
- | `user/[id].ts` | `/user/:id` |
416
- | `[org]/[repo].ts` | `/:org/:repo` |
417
- | `[...slug].ts` | `/**:slug` |
646
+ | File | Pattern |
647
+ | ------------------------- | --------------- |
648
+ | `index.ts` | `/` |
649
+ | `about.ts` | `/about` |
650
+ | `[id].ts` | `/:id` |
651
+ | `user/[id].ts` | `/user/:id` |
652
+ | `[org]/[repo].ts` | `/:org/:repo` |
653
+ | `[...slug].ts` | `/**:slug` |
654
+ | `(auth)/sign-in.ts` | `/sign-in` |
655
+ | `(auth)/[token].ts` | `/:token` |
656
+ | `(shop)/products/[id].ts` | `/products/:id` |
418
657
 
419
658
  `.test.ts`, `.spec.ts`, and `.d.ts` files are automatically excluded.
420
659
 
660
+ ### Route groups
661
+
662
+ Folders wrapped in parentheses — `(name)` — are **route groups**. They organise files without contributing a segment to the URL. The group name is completely invisible to the router.
663
+
664
+ ```
665
+ src/pages/
666
+ (auth)/
667
+ sign-in.ts → /sign-in ✓ (not /auth/sign-in)
668
+ sign-up.ts → /sign-up ✓
669
+ (marketing)/
670
+ index.ts → / ✓
671
+ pricing.ts → /pricing ✓
672
+ ```
673
+
674
+ Route groups are useful for:
675
+
676
+ - **Shared layouts without a shared URL prefix** — place a `+layout.ts` inside `(auth)/` and it wraps only those pages, with no `/auth` prefix in the URL.
677
+ - **Organising large page trees** — split pages into logical sections (`(admin)`, `(public)`, `(shop)`) while keeping flat URLs.
678
+ - **Co-locating related pages** — keep sign-in, sign-up, and password reset together in `(auth)/` for clarity.
679
+
680
+ > Groups can be nested: `(a)/(b)/page.ts` → `/page`. Both group folders are transparent.
681
+
682
+ > If two files in different groups resolve to the **same pattern** (e.g. `(auth)/sign-in.ts` and `sign-in.ts` both produce `/sign-in`), the plugin warns about a duplicate pattern and the first match wins deterministically.
683
+
421
684
  ### Route sorting
422
685
 
423
686
  Routes are sorted automatically by specificity — no need to order files manually:
@@ -426,7 +689,7 @@ Routes are sorted automatically by specificity — no need to order files manual
426
689
  2. **Parameterised** paths (`/user/:id`)
427
690
  3. **Wildcard** paths (`/**:slug`) — lowest priority
428
691
 
429
- Within the same tier, longer segment counts and alphabetical order act as tiebreakers for determinism.
692
+ Within the same tier, longer segment counts and alphabetical order act as tiebreakers for determinism. Route group pages sort alongside regular pages by their resolved pattern — the group folder is transparent.
430
693
 
431
694
  ### Layouts
432
695
 
@@ -434,8 +697,28 @@ A `+layout.ts` wraps every page in its directory and all subdirectories. Layouts
434
697
 
435
698
  ```ts
436
699
  // src/pages/+layout.ts
437
- import { html } from "ilha";
700
+ import { defineLayout } from "@ilha/router";
701
+ import ilha, { html } from "ilha";
702
+
703
+ export default defineLayout((children) =>
704
+ ilha.render(
705
+ () => html`
706
+ <nav>
707
+ <a href="/">Home</a>
708
+ <a href="/about">About</a>
709
+ </nav>
710
+ <main>${children}</main>
711
+ `,
712
+ ),
713
+ );
714
+ ```
715
+
716
+ Alternatively, using the explicit type annotation:
717
+
718
+ ```ts
719
+ // src/pages/+layout.ts — using satisfies (equivalent)
438
720
  import type { LayoutHandler } from "@ilha/router/vite";
721
+ import ilha, { html } from "ilha";
439
722
 
440
723
  export default ((children) =>
441
724
  ilha.render(
@@ -449,13 +732,62 @@ export default ((children) =>
449
732
  )) satisfies LayoutHandler;
450
733
  ```
451
734
 
735
+ A `+layout.ts` inside a route group folder works exactly like a regular nested layout — it wraps only the pages inside that group, without affecting pages elsewhere.
736
+
737
+ ```
738
+ src/pages/
739
+ +layout.ts ← wraps ALL pages (including those in groups)
740
+ (auth)/
741
+ +layout.ts ← wraps (auth) pages only: /sign-in, /sign-up
742
+ sign-in.ts
743
+ sign-up.ts
744
+ about.ts ← wrapped by root layout only
745
+ ```
746
+
747
+ ### Page loaders
748
+
749
+ A page file can export a `load` function declared with the `loader()` helper. The Vite plugin automatically detects the named `load` export, composes it with any layout loaders in the chain (outermost first, then page), and wires them into the router via `.attachLoader()` at SSR time.
750
+
751
+ ```ts
752
+ // src/pages/user/[id].ts
753
+ import { loader } from "@ilha/router";
754
+ import ilha from "ilha";
755
+
756
+ export const load = loader(async ({ params }) => {
757
+ const user = await fetchUser(params.id);
758
+ return { user };
759
+ });
760
+
761
+ export default ilha.input<{ user: User }>().render((input) => `<h1>${input.user.name}</h1>`);
762
+ ```
763
+
764
+ The `load` export must be declared with the `loader()` helper so the Vite plugin can identify it via export name.
765
+
766
+ ### Layout loaders
767
+
768
+ A `+layout.ts` can also export a loader. Layout loaders run concurrently with the page loader. The page loader wins on key collision.
769
+
770
+ ```ts
771
+ // src/pages/+layout.ts
772
+ import { defineLayout, loader } from "@ilha/router";
773
+
774
+ export const load = loader(async () => {
775
+ return { currentUser: await getCurrentUser() };
776
+ });
777
+
778
+ export default defineLayout((children) => /* … */);
779
+ ```
780
+
781
+ Layout loaders are composed automatically — you do not need to call `composeLoaders()` manually.
782
+
452
783
  ### Error boundaries
453
784
 
454
- A `+error.ts` catches any error thrown during rendering of pages in its directory and all subdirectories. The nearest boundary wins. If an inner boundary re-throws, the next outer boundary takes over. Receives the error and the current route snapshot.
785
+ A `+error.ts` catches any error thrown during rendering of pages in its directory and all subdirectories. The nearest boundary wins. If an inner boundary re-throws, the next outer boundary takes over.
455
786
 
456
787
  ```ts
457
788
  // src/pages/+error.ts
458
789
  import type { ErrorHandler } from "@ilha/router/vite";
790
+ import ilha from "ilha";
459
791
 
460
792
  export default ((error, route) =>
461
793
  ilha.render(
@@ -471,17 +803,19 @@ export default ((error, route) =>
471
803
 
472
804
  ### Virtual modules
473
805
 
474
- The plugin exposes two virtual modules:
806
+ The plugin exposes three virtual modules:
475
807
 
476
808
  | Module | Export | Description |
477
809
  | --------------- | ------------ | -------------------------------------------- |
478
810
  | `ilha:pages` | `pageRouter` | A `RouterBuilder` with all routes registered |
479
811
  | `ilha:registry` | `registry` | `Record<string, Island>` for hydration |
812
+ | `ilha:loaders` | — | Side-effect import that wires server loaders |
480
813
 
481
814
  ```ts
482
815
  // routes/[...].ts — Nitro catch-all handler
483
816
  import { pageRouter } from "ilha:pages";
484
817
  import { registry } from "ilha:registry";
818
+ import "ilha:loaders"; // ← wire server loaders
485
819
 
486
820
  export default defineEventHandler(async (event) => {
487
821
  const html = await pageRouter.renderHydratable(event.node.req.url ?? "/", registry);
@@ -508,13 +842,13 @@ pages({
508
842
  });
509
843
  ```
510
844
 
511
- The plugin regenerates the routes file only when content actually changes — avoiding unnecessary HMR invalidations. Structural changes (file add/remove, `+layout.ts`/`+error.ts` edits) trigger full HMR. Regular page content edits are handled by Vite's normal module HMR.
845
+ The plugin regenerates the routes file only when content actually changes — avoiding unnecessary HMR invalidations. Structural changes (file add/remove, `+layout.ts`/`+error.ts` edits, or changes to loader exports) trigger full HMR reloads.
512
846
 
513
847
  ---
514
848
 
515
849
  ## SSR + Hydration
516
850
 
517
- The same route config runs on both sides. Signals (`routePath`, `routeParams`, etc.) are populated identically by `.render()`/`.renderHydratable()` on the server and `.mount()`/`.hydrate()` on the client.
851
+ The same route config runs on both sides. Signals (`routePath`, `routeParams`, etc.) are populated identically by `.render()`/`.renderHydratable()` on the server and `.mount()`/`.hydrate()` on the client:
518
852
 
519
853
  ```ts
520
854
  // server: resolves URL → hydratable HTML string
@@ -531,7 +865,7 @@ routeParams(); // → { id: "99" }
531
865
 
532
866
  ```
533
867
  server client
534
- ────────────────────────────── ──────────────────────────────────────────────
868
+ ────────────────────────────── ───────────────────────────────────
535
869
  renderHydratable(url, registry) pageRouter.prime() ← sync signals first
536
870
  → data-ilha="…" markers mount(registry, { root }) ← hydrate islands
537
871
  → data-ilha-state snapshot pageRouter.mount(target, ← setup navigation
@@ -540,6 +874,21 @@ renderHydratable(url, registry) pageRouter.prime() ← sync signals firs
540
874
 
541
875
  Or use the one-liner: `pageRouter.hydrate(registry)`.
542
876
 
877
+ ### Loader data flow
878
+
879
+ On the **server**, loaders run inside `.renderHydratable()` / `.renderResponse()`. Their return value is serialized into `data-ilha-props` on the island element so the client can rehydrate without re-fetching.
880
+
881
+ On the **client**, navigations fetch loader data from the `/__ilha/loader` endpoint before mounting the next island. The endpoint is served automatically by the Vite plugin (dev) and the Nitro adapter (production).
882
+
883
+ ```
884
+ server client (navigation)
885
+ ──────────────────────────── ─────────────────────────────────────
886
+ renderHydratable GET /__ilha/loader?path=/user/42
887
+ → executeLoader(…) → runLoader("/user/42")
888
+ → island.hydratable(props) → fetchLoaderData("/user/42")
889
+ → data-ilha-props="{…}" → mountRouteWithHydration(island, host, …)
890
+ ```
891
+
543
892
  ---
544
893
 
545
894
  ## License