hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
Styling
Composing Hono Middleware
WebSockets
Rooms & Presence
renderPage
Link Prefetch
Build & Deploy
Overview
Dialog
Popover
Tooltip
Menu
Context Menu
Select
Combobox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition
usePositioner
useDismiss
useFocusReturn

Server Loaders#

Pages need data. Loaders run on the server: a direct function call during SSR, and an automatic RPC call during client-side navigation, so you never write the branch manually.

A loader is either route-bound (declared via serverRoute(r).loader) or route-independent (declared via defineLoader). Route-bound loaders receive a typed location with the route's path and search params. Route-independent loaders receive a lighter context without location, suitable for shared or reusable fetching logic.

Route-bound loaders#

Bind a server module to its route with serverRoute(r). Calling .loader(fn) on the result types ctx.location.pathParams from the route pattern automatically:

// src/pages/movie.server.ts
import { serverRoute } from 'hono-preact';
import { getMovie } from '@/server/movies.js';

const route = serverRoute('/movies/:id');

export const serverLoaders = {
  default: route.loader(async ({ location }) => {
    // location.pathParams.id is typed `string` from the `/movies/:id` pattern
    return getMovie(location.pathParams.id);
  }),
};

Then consume the data in the page component via .View():

// src/pages/movie.tsx
import { definePage } from 'hono-preact';
import { serverLoaders } from './movie.server.js';

const MovieView = serverLoaders.default.View(({ data }) =>
  data ? (
    <article>
      <h1>{data.title}</h1>
    </article>
  ) : (
    <p>Loading...</p>
  )
);

export default definePage(MovieView);

src/routes.ts wires the URL to the view; the colocated movie.server.ts is picked up with it:

import { defineRoutes } from 'hono-preact';

export default defineRoutes([
  {
    path: '/movies/:id',
    view: () => import('./pages/movie.js'),
  },
]);

Validating params and search params#

Pass paramsSchema or searchSchema (any Standard Schema library) to validate and coerce the input before the loader runs. When validation fails, paramsSchema causes a 404; searchSchema causes a 400 and the loader's error boundary catches it:

import { serverRoute } from 'hono-preact';
import { z } from 'zod';

const route = serverRoute('/movies/:id');

export const serverLoaders = {
  default: route.loader(
    async ({ location }) => {
      return getMovie(location.pathParams.id, location.searchParams.quality);
    },
    {
      searchSchema: z.object({ quality: z.enum(['hd', 'sd']).default('hd') }),
    }
  ),
};

Route-independent loaders#

defineLoader(fn) defines a loader not tied to any specific route. The context carries c (the Hono Context), signal, and call; there is no location. Use this for shared utilities, loaders reused across multiple routes, or data that does not depend on the current URL:

// src/pages/movies.server.ts
import { defineLoader } from 'hono-preact';
import { getMovies } from '@/server/movies.js';

export const serverLoaders = {
  default: defineLoader(async ({ c, signal }) => {
    const token = getCookie(c, 'session');
    const movies = await getMovies({ token, signal });
    return { movies };
  }),
};

When path or search params are needed, use serverRoute(r).loader() instead.

A route-independent loader is not route-gated. Its RPC endpoint (POST /__loaders) runs only the app-level use plus the loader's own unit-level use; it does not inherit a route node's page-layer use, even when the loader module is colocated under a guarded route. To gate a loader by a route's middleware chain, bind it with serverRoute(r).loader (the page-layer use then resolves from that exact route pattern) or attach a unit-level use to the loader itself. See Middleware for the three layers. In dev, the server warns once per loader when a route-independent loader serves a request whose matched route declares use, so an ungated RPC under a guarded subtree is visible in the console.

Streaming loaders#

A loader whose function body is an async function* is a streaming loader. The framework infers this from the function shape at build time. Streaming loaders are SSR-pumped by default: the server starts writing the response immediately, flushing each yielded chunk as an inline <script> tag:

// src/pages/dashboard.server.ts
import { defineLoader } from 'hono-preact';

export const serverLoaders = {
  metrics: defineLoader(async function* ({ signal }) {
    while (!signal.aborted) {
      yield await currentMetrics();
      await delay(1000);
    }
  }),
};

A streaming loader exposes the accumulating .View(render, { initial, reduce }) form. See Streaming for the full consumption reference.

The { live } flag#

By default, a streaming loader runs during SSR and pumps chunks into the HTML. Pass { live: true } to skip SSR entirely, making the loader a client-only subscription. This is appropriate for unbounded generators that must not hang the document response:

import { defineLoader } from 'hono-preact';

export const serverLoaders = {
  activity: defineLoader(
    async function* ({ signal }) {
      for await (const event of subscribeToActivity(signal)) {
        yield event;
      }
    },
    { live: true }
  ),
};

liveStream#

liveStream({ topic, load }) is a generator helper that re-runs a loader on every publish to a channel topic. It yields load(ctx) on initial connect, then re-yields each time publish() fires on topic(ctx). Because liveStream produces an inherently unbounded subscription, it is always live: no { live: true } flag is required. Compose it with either defineLoader or route.loader:

import { serverRoute, liveStream } from 'hono-preact';
import { tasksChannel, getTasks } from '@/server/tasks.js';

const route = serverRoute('/projects/:id');

export const serverLoaders = {
  tasks: route.loader(
    liveStream({
      topic: ({ location }) => tasksChannel.key(location.pathParams.id),
      load: ({ location }) => getTasks(location.pathParams.id),
    })
  ),
};

See Realtime Channels for channel setup.

Streaming lifecycle#

Loader typeliveBehavior
Finite (Promise<T>)n/aSSR-awaited; arrives in the initial HTML as a static value
Streaming (AsyncGenerator<T>)falseSSR-pumped; chunks flush inline into the HTML (default)
Streaming (AsyncGenerator<T>)trueSkipped on SSR; starts as a client-only subscription

Both route-bound and route-independent loaders support the streaming and live forms.

Consuming loaders#

.View(render): single-value loaders#

.View(render) creates a component pre-wrapped in the loader's error boundary, data context, and reload context. The render function receives a LoaderState<Serialize<T>> discriminated union; switch on status:

import { definePage } from 'hono-preact';
import { serverLoaders } from './movies.server.js';

const MoviesView = serverLoaders.default.View(({ data, status }) => {
  // `data` is absent on the cold `loading` arm; a truthy check is
  // enough to guard it before reading sub-fields.
  if (!data) return <p>Loading...</p>;
  if (status === 'error') return <p>Failed to load.</p>;
  return (
    <ul>
      {data.movies.map((m) => (
        <li key={m.id}>{m.title}</li>
      ))}
    </ul>
  );
});

export default definePage(MoviesView);

The loading arm carries no data. success, revalidating, and error each carry data narrowed to Serialize<T>. The revalidating status is the stale-while-revalidate state during a reload; the error arm keeps the last good value. Call useReload() for the imperative reload().

Prop passthrough (using the generic P):

const MovieCard = serverLoaders.default.View<{ highlight: boolean }>(
  ({ data, highlight }) => {
    if (!data) return <MovieSkeleton />;
    return (
      <article class={highlight ? 'highlight' : undefined}>
        <h2>{data.title}</h2>
      </article>
    );
  }
);

// Use site:
<MovieCard highlight={true} />;

Error handling inside the render function:

When the loader rejects after already succeeding once, the status is error and the last good data is still available. Read reload from useReload() to let the user retry:

import { useReload } from 'hono-preact';

const StatsView = statsLoader.View(({ status, data }) => {
  const { reload } = useReload();
  if (!data) return <StatsSkeleton />;
  if (status === 'error') return <button onClick={reload}>Retry</button>;
  return <p>Count: {data.count}</p>;
});

This error arm handles a reload that fails after the first load succeeded. A cold first-load failure routes to the error boundary and errorFallback instead. See Loading States for the distinction.

During SSR, a cold first-load failure with a local errorFallback (including a loader that throws deny(status, message)) renders that errorFallback into the full document at the failure's status, and hydrates without refetching. A loader that fails without a local errorFallback responds with the status and a plain-text body.

.View(render, { initial, reduce }): streaming loaders#

Streaming loaders use the accumulating form. Each chunk is folded into accumulated state and the render function receives a StreamState<Acc> discriminated union:

const ActivityBar = serverLoaders.activity.View<ActivityEvent[]>(
  (s) => {
    if (s.status === 'connecting') return <p>Connecting...</p>;
    if (s.status === 'error') return <p>Stream disconnected.</p>;
    return (
      <ul>
        {s.data.map((e) => (
          <li key={e.id}>{e.actor}</li>
        ))}
      </ul>
    );
  },
  {
    initial: [],
    reduce: (acc, event) => [event, ...acc].slice(0, 50),
  }
);

See Streaming and Live Loaders for the full streaming consumption reference.

loader.useData() and loader.Boundary#

loader.useData() returns the same LoaderState for descendants that need it without prop-drilling. Call it inside a .View() render function or inside an explicit loader.Boundary:

function HeaderWithSummary() {
  const { data } = serverLoaders.summary.useData();
  if (!data) return <Skeleton />;
  return <h1>{data.title}</h1>;
}

function Header() {
  return (
    <serverLoaders.summary.Boundary>
      <HeaderWithSummary />
    </serverLoaders.summary.Boundary>
  );
}

loader.useData() is not available on streaming loaders (they have no single value). Calling it on a streaming loader throws with a clear error message; use the accumulating .View form instead.

The serverLoaders container#

Loaders are exported in a named serverLoaders container from a .server.* file. Whether a module has one loader or many, the shape is always the same:

// src/pages/movies.server.ts
import { defineLoader } from 'hono-preact';

export const serverLoaders = {
  default: defineLoader(async ({ c }) => {
    const movies = await getMovies(c.env.MY_KV);
    return { movies };
  }),
};

A single-loader page conventionally uses the key default. Multi-loader pages give each loader a descriptive name. The plugin treats all entries the same way.

Multiple loaders per route#

Declare each data source as its own loader in serverLoaders. Each one gets its own independent error boundary, cache key, and streaming section, so a slow source never blocks the rest of the page:

// src/pages/movie.server.ts
import { serverRoute, defineLoader } from 'hono-preact';

const route = serverRoute('/movies/:id');

export const serverLoaders = {
  summary: route.loader(async ({ location }) =>
    getMovie(location.pathParams.id)
  ),

  cast: route.loader(async function* ({ location, signal }) {
    for await (const member of streamCast(location.pathParams.id, signal)) {
      yield member;
    }
  }),

  similar: defineLoader(async ({ c }) => getFeaturedMovies(c.env.MY_KV)),
};
// src/pages/movie.tsx
import { definePage, useReload } from 'hono-preact';
import { serverLoaders } from './movie.server.js';

const { summary, cast, similar } = serverLoaders;

const Summary = summary.View(({ status, data }) => {
  const { reload } = useReload();
  if (!data) return <SummarySkeleton />;
  if (status === 'error') return <ErrorBox onRetry={reload} />;
  return <SummaryCard data={data} />;
});

const Cast = cast.View(
  (s) => (s.status === 'open' ? <CastList items={s.data} /> : <CastSkeleton />),
  { initial: [], reduce: (acc, m) => [...acc, m] }
);

const Similar = similar.View(({ data }) =>
  data ? <SimilarCarousel items={data} /> : null
);

function MovieDetail() {
  return (
    <article>
      <Summary />
      <Cast />
      <Similar />
    </article>
  );
}

export default definePage(MovieDetail);

Each .View component loads independently. <Summary /> can show content while <Cast /> is still streaming.

Layout-level loaders#

A loader in layout.server.* is scoped to the layout's matched location. It does not re-fire when navigating between child routes:

// src/pages/movies/layout.server.ts
import { serverRoute } from 'hono-preact';

// Subtree scope: the gates every /movies child inherits.
const route = serverRoute('/movies/*');

export const serverLoaders = {
  activity: route.loader(
    async function* ({ signal }) {
      for await (const event of subscribeToActivity(signal)) {
        yield event;
      }
    },
    { live: true }
  ),
};
// src/pages/movies/layout.tsx
import { serverLoaders } from './layout.server.js';

const Feed = serverLoaders.activity.View(
  (s) => (s.status === 'open' ? <ActivitySidebar events={s.data} /> : null),
  { initial: [], reduce: (acc, e) => [e, ...acc].slice(0, 50) }
);

export default function MoviesLayout({
  children,
}: {
  children: ComponentChildren;
}) {
  return (
    <div class="movies-layout">
      <main>{children}</main>
      <aside>
        <Feed />
      </aside>
    </div>
  );
}

Navigating between child routes does not unmount the layout, so the streaming subscription continues across those navigations.

Binding a layout's loaders#

A layout server module has two bindable spellings, and they name different scopes:

  • serverRoute('/movies') is the page scope: the deepest composed chain for that pattern string. A layout and its empty-path index child share the string, and guard resolution uses the deepest node's chain, so this spelling runs the layout's inherited and own use plus the index child's own use when the index child declares one. Bind it when the module's units belong to the index page.
  • serverRoute('/movies/*') is the subtree scope: the layout node's own composed chain (ancestors outer-first, then the node's own use), exactly the gates every descendant of /movies inherits. The index child's additions are not included. Bind it when the module carries shell data that serves every child route, which is the usual case for a layout module.

Both spellings resolve the use chain from the declared pattern on every loader RPC, never from the request URL. The subtree pattern names the tree node at /movies and resolves its chain statically at startup; it is not a per-request deepest-match. A subtree pattern on a childless route fails loudly at startup.

Inside the layout, a bound loader resolves its location to the layout's own matched path with the wildcard remainder stripped, so ctx.location.pathParams carries exactly the bound pattern's prefix params under either spelling.

In dev, binding the exact path while the index child's own use widens the chain logs a one-time hint naming both spellings.

The loader context#

A route-bound loader (serverRoute(r).loader(fn)) receives:

FieldTypeDescription
cContextThe request's Hono Context. Use for cookies, headers, Bindings, the request URL, etc.
locationRouteHookThe matched route's navigation info: typed pathParams, searchParams, path, and metadata. The same shape as useLocation() accessed from a component.
signalAbortSignalAborts when the user navigates away mid-load. Forward to fetch and cancellable work.
callServerCaller['call']Invoke another loader or action server-side without an HTTP round-trip.

A route-independent loader (defineLoader(fn)) receives the same fields except location.

location.pathParams and location.searchParams are plain objects, so read them any way you like: pathParams.id, 'id' in pathParams, Object.keys(pathParams), and the rest all work. You never have to worry about a param name clashing with a built-in like constructor or toString, because a route can't declare one in the first place: serverRoute('/plugin/:constructor') throws at definition. The same holds for a socket's data params and a room's onJoin params; see WebSockets and Rooms.

Pass the signal to upstream fetch calls and subscriptions so they clean up promptly:

defineLoader(async ({ c, signal }) => {
  const token = getCookie(c, 'session');
  const res = await fetch('/api/data', { signal });
  return res.json();
});

By convention, loaders read; actions write. Setting cookies or headers from a loader is allowed but discouraged. On the SSR path, setCookie(ctx.c, …) from a non-streaming loader survives to the response. A streaming loader can set cookies only before its first yield; anything written after is dropped because the response headers are already committed.

Search-param dependencies (params)#

By default, a loader's cache key includes only the path and path params. Declare search-param dependencies per loader with the params option:

const route = serverRoute('/movies/:id');

export const serverLoaders = {
  // path-only cache key (default); never refetches on search-param changes
  summary: route.loader(async ({ location }) =>
    getMovie(location.pathParams.id)
  ),

  // refetches when ?genre changes, but not on other params
  similar: route.loader(
    async ({ location }) =>
      fetchSimilar(location.pathParams.id, location.searchParams.genre),
    { params: ['genre'] }
  ),

  // refetches on any search-param change; for search/filter pages
  results: route.loader(
    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.

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:

import { useAction } from 'hono-preact';
import { serverLoaders as moviesLoaders } from './movies.server.js';
import { serverActions } from './reviews.server.js';

const { mutate } = useAction(serverActions.addReview, {
  invalidate: [moviesLoaders.default],
});

Pass 'auto' to invalidate the current page's loader only. Use loader.invalidate() to clear the cache imperatively.

Caching navigation results#

Every loader gets its own cache automatically. Repeated navigations to the same route (with the same cache key) hit the cache and skip the RPC call. To clear the cache:

serverLoaders.default.invalidate();

Sharing a cache between loaders#

If two loaders need to share storage, 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 }),
};

Cache registry scope#

The loader-cache registry is keyed off Symbol.for('@hono-preact/iso/loaderCaches'), shared across every consumer of @hono-preact/iso in the same JavaScript realm. On Cloudflare Workers each request runs in a short-lived isolate, so this is effectively per-process. On a long-lived Node server hosting multiple tenants from one realm, the registry is shared. Cache contents are per-request via AsyncLocalStorage, so there is no cross-tenant data leak.

Timeouts#

timeoutMs sets a per-loader deadline on the loader RPC path, the request the client issues when it navigates. It defaults to 30 seconds. Override it per loader:

export const serverLoaders = {
  slowReport: defineLoader(
    async () => {
      /* ... */
    },
    { timeoutMs: 60_000 }
  ),
  liveStream: defineLoader(
    async function* () {
      /* yields indefinitely */
    },
    { timeoutMs: false }
  ),
};

Pass timeoutMs: false to opt out entirely. When the deadline fires, the loader's ctx.signal aborts, the server responds with 504, and the client receives a TimeoutError.

timeoutMs governs the loader RPC path (client navigation) only. It is not enforced during the initial SSR render: there the loader runs inline as the page prerenders, with no timeoutMs deadline applied.

Page bindings with definePage#

Per-page bindings (Wrapper, errorFallback) live with the page component. definePage captures them:

import { definePage } from 'hono-preact';

export default definePage(Component, { Wrapper });

Loader consumption (.View()) lives inside the page's JSX tree, not on definePage.

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. If you ever need a custom server entry, see renderPage for the manual wiring contract.

How it works#

A page is a file pair: a .server.ts module that collects loaders in serverLoaders, and a .tsx component that consumes data through .View() or useData().

At runtime:

  1. SSR: each loader in serverLoaders runs directly during prerender. Its return value is JSON-serialized and embedded in the page's HTML.
  2. Hydration (first load): the client reads that embedded data. No fetch fires.
  3. Client-side navigation: the Vite plugin replaces .server.* imports with Proxy stubs. Accessing serverLoaders.name returns a LoaderRef whose RPC stub POSTs { module, loader, location } to POST /__loaders. The server runs the real function and returns JSON.

Because every value crosses this boundary as JSON, the client receives the serialized shape of a loader's return, not the server-side type. The data hooks reflect that honestly: the value carried by loader.useData() and the .View() render argument is Serialize<T>, the JSON round-trip of the loader's return T. A Date field is therefore typed (and arrives) as a string; values JSON cannot carry (functions, bigint, symbols) are dropped, or surface as never so a non-serializable return is a compile error.

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. serverLoaderValidationPlugin fails the build if a .server.* file has unrecognised named exports.

Options#

Pass a second argument to defineLoader or route.loader to configure a loader:

OptionTypeDefaultDescription
paramsstring[] | '*'[]Search params that change the cache key; '*' means any. Route-bound only.
cacheLoaderCache<T>autoShared cache; see Caching navigation results.
timeoutMsnumber | false30000Per-loader deadline in milliseconds; false disables it.
livebooleanfalseSkip SSR; start as a client-only subscription (streaming loaders only).
useLoaderUsenonePer-loader middleware and stream observers.
paramsSchemaStandardSchemaV1noneRoute-bound only: validates and coerces pathParams. Fails with 404.
searchSchemaStandardSchemaV1noneRoute-bound only: validates and coerces searchParams. Fails with 400.

Most options have a dedicated section above; this table is the at-a-glance summary.

Inference types#

InferLoaderData<L> extracts a loader ref's data type without re-importing the loader definition. It resolves to the authored server-side type, not the serialized wire shape.

import type { InferLoaderData, Serialize } from 'hono-preact';
import type { serverLoaders } from './movies.server.js';

// The loader's return type as declared (server-side, pre-serialization)
type Data = InferLoaderData<typeof serverLoaders.default>;
// { movies: MovieList } (a Date field stays Date here)

// The wire shape (JSON round-trip): Date degrades to string, etc.
type WireData = Serialize<InferLoaderData<typeof serverLoaders.default>>;

InferLoaderData<L> is useful for code that only runs server-side (such as fixtures, adapters, and createCaller test helpers) where the pre-serialized type is the right type to work with. The client-facing hooks (.View(), loader.useData()) always carry Serialize<T>, because data crosses the server/client boundary as JSON.

API reference#

HelperResolves to
InferLoaderData<L>The loader's data type (T), pre-serialization

See also#