Server Loaders
Pages often need data. On the server, that data should come from a direct function call. In the browser during navigation, it goes through an RPC call to the server. Writing this branch manually is error-prone, so the loader system handles it automatically.
If your loader produces values over time (dashboards, log tails, live feeds), see Streaming.
How it works
A page is a file pair:
movies.server.ts: the server loaders collected in aserverLoaderscontainer. Never reaches the browser bundle.movies.tsx: a pure component that consumes each loader's data via.View()(orloader.useData()inside aloader.Boundary) and is exported throughdefinePage(Component).
definePage(Component) returns a routable component that self-wraps in <Page> with any page-level bindings. The route table in routes.ts declares which view and which .server.ts live at each URL via the view and server fields; the framework wires the rest.
At runtime:
- SSR: each loader in
serverLoadersruns directly duringprerender. Its return value is JSON-serialized and embedded in the page's HTML. - Hydration (first load): the client reads that embedded data. No fetch is fired.
- Client-side navigation: the Vite plugin replaces the
serverLoadersimport with a Proxy stub. AccessingserverLoaders.namereturns aLoaderRefwhose RPC stub POSTs{ module, loader, location }toPOST /__loaders. The server runs the real function and returns JSON.
The serverLoaders container
Loaders are exported in a named container symmetric with serverActions. Every .server.* file uses this shape, whether it has one loader or many:
// src/pages/movies.server.ts
import { defineLoader } from 'hono-preact';
export const serverLoaders = {
default: defineLoader(async () => {
const movies = await getMovies();
return { movies };
}),
};
A single-loader page conventionally uses the key default. Multi-loader pages give each loader a descriptive name. There is no special-cased single-loader export; the plugin treats all entries the same way.
Options
Pass a second argument to defineLoader to configure a loader:
| Option | Type | Default | Description |
|---|---|---|---|
params | string[] | '*' | [] | Search params that change the cache key; '*' means any. |
cache | LoaderCache<T> | auto | A shared cache; see Caching navigation results. |
timeoutMs | number | false | 30000 | Per-loader deadline; false disables it. |
use | LoaderUse | none | Per-loader middleware and stream observers. |
Each option has a fuller section below; this table is the at-a-glance summary.
Example: listing page
Snippets on this page assume:
type MovieList = { results: { id: number; title: string }[] };
type Movie = { id: number; title: string };
src/pages/movies.server.ts holds the server-only data fetching:
import { getMovies } from '@/server/movies.js';
import { defineLoader } from 'hono-preact';
export const serverLoaders = {
default: defineLoader(async () => {
const movies = await getMovies(); // direct call; never runs in the browser
return { movies };
}),
};
src/pages/movies.tsx creates a self-contained view component via .View(), then passes it to definePage:
import { definePage } from 'hono-preact';
import { serverLoaders } from './movies.server.js';
const dataLoader = serverLoaders.default;
const MoviesView = dataLoader.View(({ data }) => (
<ul>
{data.movies.results.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
));
export default definePage(MoviesView);
src/routes.ts wires the URL to the view and its server module:
import { defineRoutes } from 'hono-preact';
export default defineRoutes([
// ...
{
path: '/movies',
view: () => import('./pages/movies.js'),
server: () => import('./pages/movies.server.js'),
},
]);
defineLoader(fn) returns a typed LoaderRef<T>. The Vite moduleKeyPlugin rewrites the call at build time to inject { __moduleKey, __loaderName } so the RPC handler can dispatch to the correct function. definePage(Component) returns a routable component that self-wraps in <Page> with any page-level bindings captured in a closure.
Example: detail page (using route params)
The loader function receives { location } which carries location.pathParams. Use this to load a record by ID. The function's return type is inferred and propagated through defineLoader(fn) to all consumption points.
// src/pages/movie.server.ts
import { getMovie } from '@/server/movies.js';
import { defineLoader } from 'hono-preact';
export const serverLoaders = {
default: defineLoader(async ({ location }) => {
const movie = await getMovie(location.pathParams.id);
return { movie };
}),
};
The loader context
Every loader is called with a single context argument with the same three fields, regardless of whether it returns a value or streams:
type LoaderCtx = {
c: Context; // typed Hono Context, always available
location: RouteHook; // route-change location info
signal: AbortSignal; // aborts when the user navigates away
};
| Field | Description |
|---|---|
c | The request's Hono Context. Use it for headers, cookies, KV/D1/env Bindings, the request URL, etc. With typed Bindings, narrow inside the function body. |
location | Current route navigation info (the same shape as useLocation()). Carries pathParams, searchParams, the resolved path, and the route's metadata. |
signal | An AbortSignal that aborts when the user navigates away mid-load. Forward it to fetch and any cancellable server work to short-circuit on navigation. |
Destructure whichever fields a loader needs:
defineLoader(async ({ c, location, signal }) => {
const token = getCookie(c, 'session');
const res = await fetch(`/api/movies/${location.pathParams.id}`, { signal });
return res.json();
});
ctx.c is the request's Hono Context. A server loader can read cookies (getCookie(ctx.c, …)), reach app Bindings (ctx.c.env.MY_KV), or set a response header before yielding. The shape is Context; users with typed Bindings can narrow inside the function body.
By convention, loaders read; actions write. Setting cookies/headers from a loader is allowed but discouraged.
On the SSR path, setCookie(ctx.c, …) from a non-streaming loader survives to the response. Streaming loaders can set cookies too, but only before their first yield: that part of the loader runs during the server render, while the response headers are still open. Anything written after the first yield runs while the stream is already in flight, with headers committed, and is dropped. When in doubt, rotate sessions from an action.
The .View() factory
.View(render, opts) is the primary way to consume a loader. It returns a Preact component pre-wrapped in the loader's own Suspense boundary, error boundary, data context, and reload context:
loader.View<P = {}>(
render: (args: P & { data: T; error: Error | null; reload: () => void }) => ComponentChildren,
opts?: { fallback?: ComponentChildren; errorFallback?: ComponentChildren }
): FunctionComponent<P>
The render function receives { data, error, reload } plus any props declared by the generic P. Inside the render function's subtree, loader.useData() also works for descendants that need data without prop-drilling.
Basic usage:
import { definePage } from 'hono-preact';
import { serverLoaders } from './movies.server.js';
const dataLoader = serverLoaders.default;
const MoviesView = dataLoader.View(
({ data }) => (
<ul>
{data.movies.results.map((m) => (
<li key={m.id}>{m.title}</li>
))}
</ul>
),
{ fallback: <p>Loading...</p> }
);
export default definePage(MoviesView);
Prop passthrough (using the generic P):
const MovieCard = dataLoader.View<{ highlight: boolean }>(
({ data, highlight }) => (
<article class={highlight ? 'highlight' : undefined}>
<h2>{data.movie.title}</h2>
</article>
),
{ fallback: <MovieSkeleton /> }
);
// Use site:
<MovieCard highlight={true} />;
Error handling inside the render function:
The error argument is non-null after the stream's first chunk (for streaming loaders) or on a re-fetch error. Use reload to let the user retry:
const StatsView = statsLoader.View(
({ data, error, reload }) => (
<>
{error && <button onClick={reload}>Retry</button>}
<p>Count: {data.count}</p>
</>
),
{ fallback: <StatsSkeleton /> }
);
Composing multiple loaders per route
This is the supported pattern when a route needs more than one data source. Declare each source as its own loader in the same serverLoaders container; the framework wires each one independently. There is no separate "parallel fetch" or "loader group" API; composition is just adding another key.
Each loader gets its own independent Suspense boundary, error boundary, cache key, and streaming section, so a slow or failing source never blocks the rest of the page:
// src/pages/movie.server.ts
import { defineLoader } from 'hono-preact';
export const serverLoaders = {
summary: defineLoader(async ({ location }) =>
getMovie(location.pathParams.id)
),
cast: defineLoader(async function* ({ location }) {
for await (const member of streamCast(location.pathParams.id)) yield member;
}),
similar: defineLoader(
async ({ location }) => fetchSimilar(location.pathParams.id),
{ params: ['genre'] }
),
};
// src/pages/movie.tsx
import { definePage } from 'hono-preact';
import { serverLoaders } from './movie.server.js';
const { summary, cast, similar } = serverLoaders;
const Summary = summary.View(
({ data, error, reload }) =>
error ? <ErrorBox onRetry={reload} /> : <SummaryCard data={data} />,
{ fallback: <SummarySkeleton /> }
);
const Cast = cast.View(({ data }) => <CastList items={data} />, {
fallback: <CastSkeleton />,
});
const Similar = similar.View(({ data }) => <SimilarCarousel items={data} />, {
fallback: <SimilarSkeleton />,
});
function MovieDetail() {
return (
<article>
<Summary />
<Cast />
<Similar />
</article>
);
}
export default definePage(MovieDetail);
Each View component streams independently. <Summary /> can show content while <Cast /> and <Similar /> are still loading.
The .Boundary escape hatch
When you need to interleave loader-aware UI with non-loader content, use loader.Boundary directly and call loader.useData() inside it:
function Header() {
return (
<summary.Boundary fallback={<HeaderSkeleton />}>
<HeaderWithSummary />
</summary.Boundary>
);
}
function HeaderWithSummary() {
const data = summary.useData();
return (
<header>
<BreadcrumbTrail />
<h1>{data.title}</h1>
<ActionsBar />
</header>
);
}
loader.useData() must be called inside a matching Boundary (placed by .View() or explicitly via loader.Boundary). Calling it outside throws with a clear error message.
Search-param dependencies (params)
By default, a loader's cache key includes only the path and path params. Search params that the loader doesn't use (analytics tags, UI state, tracking tokens) don't cause refetches.
Declare search-param dependencies per loader with the params option:
export const serverLoaders = {
// path-only cache key (default); never refetches on search-param changes
summary: defineLoader(async ({ location }) =>
getMovie(location.pathParams.id)
),
// refetches when ?genre changes, but not on ?modal or ?utm_source
similar: defineLoader(
async ({ location }) =>
fetchSimilar(location.pathParams.id, location.searchParams),
{ params: ['genre'] }
),
// refetches on any search-param change; for search/filter pages
results: defineLoader(
async ({ location }) => searchMovies(location.searchParams),
{ params: '*' }
),
};
The RPC request always sends the full location to the server; params only narrows the client-side cache key.
Layout-level loaders
A loader declared in layout.server.* is automatically scoped to the layout's matched location, not the deepest child route. It does not re-fire when navigating between child routes under the same layout:
// src/pages/movies/layout.server.ts
import { defineLoader } from 'hono-preact';
export const serverLoaders = {
activity: defineLoader(async function* ({ signal }) {
for await (const event of subscribeToActivity(signal)) {
yield event;
}
}),
};
// src/pages/movies/layout.tsx
import { serverLoaders } from './layout.server.js';
const Feed = serverLoaders.activity.View(
({ data }) => <ActivitySidebar events={data} />,
{ fallback: null }
);
export default function MoviesLayout({
children,
}: {
children: ComponentChildren;
}) {
return (
<div class="movies-layout">
<main>{children}</main>
<aside>
<Feed />
</aside>
</div>
);
}
Navigating /movies to /movies/123 does not unmount the layout, does not change the layout's matched location, and therefore does not re-fire the activity loader. The streaming subscription continues across child route changes.
Registering the loader endpoint
You do not wire loadersHandler directly. The framework's Vite plugin generates the server entry, which mounts loadersHandler on POST /__loaders, a page POST handler for actions, your optional api.ts, and the SSR catch-all on one Hono app and exports it as the worker's default. loadersHandler routes by moduleKey::loaderName (the path-derived key injected by moduleKeyPlugin combined with the loader's name in the container); modules without __moduleKey are silently skipped at map-build time.
If you ever need a custom server entry, see renderPage for the manual wiring contract.
Caching navigation results
Every loader gets its own cache automatically. Repeated navigations to the same page (with the same cache key) hit the cache and skip the RPC call. To clear the cache, call loader.invalidate(). The next navigation re-runs the loader.
// clear the cache on the current loader
dataLoader.invalidate();
Sharing a cache between loaders
If two loaders need to share storage (for example, a detail page that should populate the same cache as a list page), construct a LoaderCache explicitly and pass it via the cache option:
import { createCache, defineLoader } from 'hono-preact';
const moviesCache = createCache<{ movies: MovieList }>();
export const serverLoaders = {
default: defineLoader(serverLoader, { cache: moviesCache }),
};
createCache<T>() takes no arguments. Loader caches are referenced by the loader, not by name.
Cache registry scope
The loader-cache registry that lets loader.invalidate() work across importers is keyed off a global Symbol.for('@hono-preact/iso/loaderCaches'). The map is therefore shared across every consumer of @hono-preact/iso running in the same JavaScript realm.
On Cloudflare Workers (the framework's primary target) each request runs in a short-lived isolate, so this is effectively per-process and per-tenant. On a long-lived Node server hosting multiple tenants from one realm, the registry is shared across tenants. The cache contents are NOT cross-leaking (each loader's cache is keyed by its own __moduleKey + a private symbol minted at defineLoader time), but multi-tenant operators should be aware that the framework assumes per-realm isolation.
Page bindings with definePage
Per-page concerns (Wrapper, errorFallback, middleware via use) live with the page component, not with the route declaration. definePage captures them:
import { definePage } from 'hono-preact';
export default definePage(Component, { Wrapper });
Loader and fallback bindings live on serverLoaders.name.View() inside the page's JSX tree, not on definePage.
A page with no loader simply skips definePage and exports the component directly:
// src/pages/home.tsx
const Home = () => <main>Welcome.</main>;
export default Home;
A Wrapper binding wraps the page in a custom element while keeping the SSR plumbing:
// src/pages/movie.tsx
import { definePage, type WrapperProps } from 'hono-preact';
import { serverLoaders } from './movie.server.js';
const { default: movieLoader } = serverLoaders;
const MovieView = movieLoader.View(
({ data }) => <article>{data.movie.title}</article>,
{ fallback: <MovieSkeleton /> }
);
function MovieWrapper(props: WrapperProps) {
return <article {...props} />;
}
export default definePage(MovieView, { Wrapper: MovieWrapper });
Cross-page invalidation
When a mutation on one page should refresh data on another, import the other page's loader ref and pass it to useAction({ invalidate: [...] }). Invalidation is by reference, not by name:
// movies.server.ts
import { defineLoader } from 'hono-preact';
export const serverLoaders = {
default: defineLoader(async () => ({ movies: await getMovies() })),
};
// reviews.tsx: a different page whose action should also refresh the movie list
import { useAction } from 'hono-preact';
import { serverLoaders as moviesLoaders } from './movies.server.js';
import { serverActions } from './reviews.server.js';
const moviesLoader = moviesLoaders.default;
const { mutate } = useAction(serverActions.addReview, {
invalidate: [moviesLoader], // clears moviesLoader's cache on success
});
invalidate accepts an array of loader refs, so a single action can refresh multiple targets: invalidate: [moviesLoader, ratingsLoader]. Use 'auto' instead of an array to invalidate the current page's loader only.
Timeouts
Loaders get a deadline. By default every call has 30 seconds to finish; the
deadline starts when the handler receives the request. Pass timeoutMs on
defineLoader to override:
export const slowReport = defineLoader(
async () => {
/* ... */
},
{ timeoutMs: 60_000 }
);
Pass timeoutMs: false to opt out entirely (useful for long-running streams):
export const longLivedStream = defineLoader(
async function* () {
/* yields indefinitely */
},
{ timeoutMs: false }
);
When a deadline fires, the loader's ctx.signal aborts with reason
DOMException('TimeoutError'). The server responds with status 504 and a
{ __outcome: 'timeout', timeoutMs } envelope. On the client, the failure
surfaces as a TimeoutError instance (with kind: 'timeout' and the original
timeoutMs as class properties).
The framework default is configurable per handler via
loadersHandler(glob, { defaultTimeoutMs }).
The server/client boundary
Two Vite plugins enforce that .server.* code never reaches the browser. serverOnlyPlugin rewrites *.server.* imports in the client bundle: the serverLoaders named export becomes a Proxy whose get(_, name) returns a fresh LoaderRef stub for that name. Any imported serverActions becomes a Proxy of action stubs; imported pageUse / loaderUse / actionUse become empty arrays. serverLoaderValidationPlugin fails the build if a .server.* file has unrecognised named exports.
See also
- Loading States: the loading and error UI for a loader's data.
- Reloading Data: invalidating and re-running loaders.
- Prefetching: warming a loader before navigation.
- Server Actions: mutations that can invalidate loaders.