Loaders
Fetch data before a page renders with load(), loader.client, redirects, errors, and composed 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";
async function getSession(_request: Request) {
return { id: "s1" };
}
async function fetchUser(id?: string, _signal?: AbortSignal) {
return id ? { name: "Ada" } : null;
}
// ---cut---
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";
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>
));
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.
Loader context
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 when a route is rendered — at frame time for server pages, and inside the SSR middleware’s /__ilha/loader endpoint for regular pages. 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 loader.client exports; manual routers can call it directly. When both a server and a client loader exist, 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. Invalid status codes are coerced to 302 silently.
import { redirect } from "@ilha/router";
redirect("/login"); // 302 by default
redirect("/moved", 301); // permanent
error(status, message)
Throws a loader error sentinel. On the server, intercept it with .renderResponse() (custom hosts) or the SSR middleware, which emits the proper HTTP status code automatically. Invalid status codes are coerced to 500 silently.
import { error } from "@ilha/router";
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.
import { composeLoaders, loader } from "@ilha/router";
const layoutLoader = loader(async () => ({
nav: [] as string[],
}));
const pageLoader = loader(async () => ({ post: { id: 1 } }));
// ---cut---
const combined = composeLoaders([layoutLoader, pageLoader]);
// → { user: …, post: … }
Used internally by the pages plugin. Also available for manual composition.