hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Head Management
Active Links
Signals
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
Server Caller
View Transitions
Middleware
Session Channels
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
Listbox
Toast
renderElement
useControllableState
mergeRefs
useListNavigation
useTypeahead
useListboxSelection
usePosition

Middleware#

Middleware is how you add auth gates, tracing, timing, and request logging to loaders, actions, and page renders without repeating that logic in each handler. Declare a use array on your app config, a route node, or an individual loader or action; the framework runs them in order and partitions server- vs client-bound members automatically.

The three layers#

Each layer attaches its use array through a different surface:

LayerWhere you declare itWhat it wraps
AppdefineApp({ use }) default-exported from src/app-config.tsEvery page render, every loader, every action.
Pageuse on a route node in src/routes.tsThe matched node and its descendants: renders, plus route-bound loaders and actions (serverRoute(r)). A bare defineLoader / defineAction RPC is route-independent (see below).
UnitdefineLoader(fn, { use }) / defineAction(fn, { use })Just that one loader or action call.
// apps/site/src/app-config.ts (optional file)
import { defineApp, defineServerMiddleware } from 'hono-preact';

const requestId = defineServerMiddleware(async (ctx, next) => {
  ctx.c.header('X-Request-Id', crypto.randomUUID());
  await next();
});

export default defineApp({ use: [requestId] });

App-level entries are dispatched in all three scopes, so defineApp({ use }) accepts only middleware that handles all three: write defineServerMiddleware(...) with no scope argument (the default), and narrow on ctx.scope before touching anything scope-specific. A defineServerMiddleware<'page'>(...) is a type error there, because the loader and action dispatches would hand it a context it never signed up for. defineClientMiddleware(...) is also a type error at the app level: app config never reaches the browser, so client middleware attaches to route nodes, whose use the client dispatcher does see.

The context field that catches people out is location. Page and loader scope always carry one; action scope carries one only for a route-bound action (serverRoute(r).action). Absent is not "unrestricted":

import { defineServerMiddleware, deny } from 'hono-preact';

const requireOrgMember = defineServerMiddleware(async (ctx, next) => {
  // A bare `defineAction` POST is route-independent and carries no location,
  // so there is no org to check against. Fail closed rather than skipping.
  if (!ctx.location) throw deny(403, 'Not route-bound');
  if (!(await mayAccess(ctx.c, ctx.location.pathParams.orgId))) {
    throw deny(403, 'Not a member of this org');
  }
  await next();
});

A render(Component) outcome is page-scope only, so an app-level entry that throws one narrows first (if (ctx.scope === 'page') throw render(...)); throwing it on a loader or action request is a 500.

// src/routes.ts
import { defineRoutes } from 'hono-preact';
import { requireSession } from './auth/session.js';

export default defineRoutes([
  { path: '/login', view: () => import('./pages/login.js') }, // public
  {
    path: '/admin',
    layout: () => import('./pages/admin/layout.js'),
    use: requireSession, // one declaration
    children: [
      {
        path: '',
        view: () => import('./pages/admin/index.js'),
        server: () => import('./pages/admin/index.server.js'),
      },
      {
        path: 'users',
        view: () => import('./pages/admin/users.js'),
        server: () => import('./pages/admin/users.server.js'),
      },
    ],
  },
]);
// src/pages/admin/index.server.ts
import { serverRoute } from 'hono-preact';
import { auditLog } from '../../audit.js';

const admin = serverRoute('/admin');

// requireSession is declared once on the /admin route node (routes.ts).
// Binding these units to that route makes their RPC paths inherit it, so no
// guard is repeated here.
export const serverLoaders = {
  default: admin.loader(async () => listAdminRows()),
};

export const serverActions = {
  promote: admin.action(async (ctx, payload) => promoteUser(payload.id), {
    use: [auditLog],
  }),
};

Chain order#

Outer-to-inner the chain is appConfig.use → node use (ancestors outer-first) → unit-level use. The dispatcher runs each before body in order, then next() enters the next ring, then each after body unwinds in reverse:

root:before
  page:before
    unit:before
      inner (loader / action body)
    unit:after
  page:after
root:after

A throw in any ring propagates up through the surrounding after blocks (so a try { await next() } finally { ... } middleware still runs its cleanup on failure).

Nested routes compose down the tree#

A use on a route node inherits down the tree: it applies to the node's own view (if any) and every descendant. It covers the render path for the whole subtree and the RPC path of any route-bound loader or action (serverRoute(r).loader / serverRoute(r).action). You declare the guard once on the ancestor; leaves under it are gated without repeating anything. A sibling route that is not a descendant of the guarded node stays ungated.

Bare units are the exception, and loaders and actions behave the same way. A route-independent defineLoader or defineAction runs its SSR render inside the route chain, but its client-driven RPC (POST /__loaders for a loader, the action POST for an action) runs only the app-level use and the unit's own unit-level use, never the route node's page-layer use. So a bare unit colocated under a guarded route is gated on first paint but not on a direct RPC hit. A bare unit is route-independent by design: the client chooses where to POST, so the route it posts from is not a security boundary. Bind it with serverRoute(r).loader / serverRoute(r).action (as in the example above), or give it a unit-level use, to gate it everywhere.

One server module can hold both kinds at once, and nothing flags the mix. A module that defines its loaders through a route binder and its actions with a plain defineAction reads as one route-bound unit, but only the loaders are gated by the route node; the actions are route-independent and their POST runs with the app-level and unit-level chains alone. Proximity in the file is not a gate. When a module is meant to sit behind a route's guard, bind every unit in it through the same binder:

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

export const serverLoaders = {
  board: route.loader(async ({ location }) =>
    loadBoard(location.pathParams.projectId)
  ),
};

export const serverActions = {
  // Bound through the same binder, so the route node's `use` gates this too.
  // A plain `defineAction` here would not be gated by the route node.
  moveCard: route.action(async (ctx, input) => moveCard(input)),
};

Mixing is legitimate when it is deliberate: an action that genuinely serves several routes, or one whose gate belongs on the unit rather than the page, is better off bare with its own use. The rule is that the choice should be explicit per unit, because reading the module will not tell you which chain each one runs.

A layout's own server module binds either scope of its node. serverRoute('/admin/*') is the subtree scope: the chain every descendant inherits (the layout's inherited and own use), the usual spelling for shell data. serverRoute('/admin') is the page scope: the deepest composed chain for the pattern string, which adds the index child's own use when the index child declares one (see Binding a layout's loaders).

A grouping node that declares use and children but no view or layout has no page pattern of its own, but its subtree does: a server module shared across the subtree binds serverRoute('/x/*') and runs the composed chain the node passes to every descendant. Under a tree-form registration (tree: typeof routeTree, see Typed route params) the '/x/*' spelling is typed like every other children-bearing node's subtree; a paths-only registration types subtree patterns heuristically from the path union, which cannot see a grouping node.

// src/routes.ts
import { defineRoutes } from 'hono-preact';
import { adminGate } from './auth/admin.js';
import { auditLog } from './audit.js';

export default defineRoutes([
  {
    path: '/admin',
    use: [adminGate], // guards everything below
    children: [
      {
        path: 'users/:id',
        view: () => import('./admin/users.js'),
        server: () => import('./admin/users.server.js'), // no redundant guard needed
      },
    ],
  },
  { path: '/public', view: () => import('./pages/public.js') }, // not a descendant: ungated
]);

A page request (SSR GET) to /admin/users/42 runs:

root:before                     // appConfig.use
  admin:before                  // /admin node use: adminGate
    audit:before                // /admin/users/:id loader unit use: auditLog
      unit:before               // defineLoader({ use })
        inner
      unit:after
    audit:after
  admin:after
root:after

When multiple ancestors each carry use, they compose outer-to-inner in tree order (outermost ancestor first, then each child toward the matched leaf, then the leaf's own use).

Server vs client middleware#

defineServerMiddleware runs on the server side: during SSR, during the page-tsx pre-render, and during loader/action RPC calls. It receives the Hono Context, so cookies, headers, and signed-cookie helpers work as you'd expect.

The context's shape follows the scope: page and loader scope carry a location (path and search params), and route-bound action scope does too. A per-resource gate declared on a route node that reads ctx.location.pathParams therefore applies to renders, loaders, and route-bound actions (serverRoute(r).action) alike. Two cases carry no location: a bare defineAction (route-independent, so bind it with serverRoute(r) or give it a unit-level use to gate it), and the in-process call() path (server-to-server, which runs no route-node middleware).

The scope argument to defineServerMiddleware picks which of those contexts the entry receives, and where it may be attached. <'loader'> gets a ServerLoaderCtx (with module and loader) and fits a loader's own use; <'action'> gets a ServerActionCtx (with module, action, payload); <'page'> gets a ServerPageCtx. Omit it and the entry accepts all three.

Omitting it is required for both of the outer tiers. An app-level use and a route node's use are each folded into that route's loader and action chains as well as its page render, so an entry in either one is dispatched with all three context shapes and must handle all three. Reach for the scope argument only on a unit's own use (a single loader's or a single action's), where exactly one shape can arrive.

defineClientMiddleware runs only on the browser side, when the user navigates intra-app. It receives a minimal context with scope: 'page' and location; there's no Hono context because there's no server request.

During a client navigation between routes that have page-layer middleware (use), the previous route stays visible while the next route's middleware chain resolves. The navigation commits and the new route mounts only after the chain completes, so the user never sees a blank interval between routes. This matches the behavior for plain lazy routes, where the router holds the outgoing content visible until the incoming route is ready. For apps that mount the page-middleware host directly (via hono-preact/internal), a Router ancestor is required as the suspense boundary; the Routes component in the generated client entry provides this automatically.

Both factories produce a brand object with a runs tag. The framework's Vite plugin strips the wrong-env body at build time: a defineServerMiddleware(...) call in the client bundle is rewritten to a no-op brand object, and vice versa. Server-only modules pulled in by a server middleware body tree-shake out of the client bundle automatically.

import {
  defineServerMiddleware,
  defineClientMiddleware,
  redirect,
} from 'hono-preact';
import { currentUser } from './session.js';

// Server check (SSR + RPC): validates the signed cookie via Hono helpers.
const requireSessionServer = defineServerMiddleware(async (ctx, next) => {
  const user = await currentUser(ctx.c);
  if (!user) throw redirect('/login');
  await next();
});

// Client check (intra-app navigation): reads a localStorage hint set by
// the login view. On full reload the server middleware reconciles drift.
const requireSessionClient = defineClientMiddleware(async (_ctx, next) => {
  if (typeof window === 'undefined') {
    await next();
    return;
  }
  if (!window.localStorage.getItem('app:authed')) {
    throw redirect('/login');
  }
  await next();
});

export const requireSession = [requireSessionServer, requireSessionClient];

Outcomes#

A middleware short-circuits by throwing one of three outcomes:

OutcomeWhere it makes senseResult
redirect(to)any scopeHTTP redirect on SSR; route(to) on the client; { __outcome: 'redirect', to } envelope on loader/action RPC
deny(status, message)any scopeHTTP response at status with message; on RPC paths the client surfaces message as the thrown Error
render(Component)page scope onlyRenders <Component /> in place of the matched page
import { redirect, deny, render } from 'hono-preact/page';

// A route guard is dispatched at page, loader AND action scope, so it takes
// the default all-scope spelling. `render` only means anything during a page
// render, so narrow before throwing it; the redirect applies to every scope.
const adminOnly = defineServerMiddleware(async (ctx, next) => {
  const user = await currentUser(ctx.c);
  if (!user) throw redirect('/login');
  if (!user.isAdmin) {
    if (ctx.scope === 'page') throw render(NotAuthorizedPage);
    throw deny('FORBIDDEN');
  }
  await next();
});

const rateLimit = defineServerMiddleware<'action'>(async (ctx, next) => {
  if (await isRateLimited(ctx.c)) throw deny(429, 'Slow down');
  await next();
});

render is page-scope only and lives at the hono-preact/page subpath, so loader/action code can't accidentally import it and trigger a render outcome is page-scope only 500 at runtime.

Typing the deny payload#

defineServerMiddleware also takes the scope as its first ARGUMENT. It behaves identically at runtime; the difference is that TypeScript can then infer both the scope and the type of the data the middleware denies with, and defineAction unions those types across a use array onto its mutate result's deny arm:

const rateLimit = defineServerMiddleware('action', async (ctx, next) => {
  const retryAfterS = await rateLimitDelay(ctx.c);
  if (retryAfterS) {
    return deny('TOO_MANY_REQUESTS', 'Slow down', { data: { retryAfterS } });
  }
  await next();
});

The type-argument spelling (defineServerMiddleware<'action'>(...)) cannot do this: naming one type argument tells TypeScript to stop inferring the rest, so the deny payload stays unknown. Both spellings are supported; reach for the argument form when a caller should be able to read the deny data with a type.

This inference only ever looks at the guards listed in the action's own use array. A guard registered at the route or app level is composed into the dispatch chain at runtime, not read by the type-level DenyOf computation, so it is invisible to TDenyData regardless of which spelling wrote it. If such a guard denies a request bound for an action with its own typed guards, the deny still arrives at the client typed as that action's deny union, which it is not actually guaranteed to match; callers should only treat deny.data as trustworthy for codes their own guards produce, and should narrow or check the shape before trusting anything else.

The scope argument in the value-passing form ('action' in defineServerMiddleware('action', fn)) is type-level only at runtime: the dispatcher already knows the scope and supplies the narrowed ctx, so the string exists purely to give TypeScript something to infer against, not to make a runtime decision.

Stream observers#

Streaming loaders and actions accept defineStreamObserver entries in the same use array. Observers are passive: they see lifecycle events but never short-circuit. The dispatcher fires onStart before the first chunk, onChunk for each chunk in order, onEnd once the stream completes, onError if it throws, and onAbort if the request signal aborts.

Failure isolation: if one observer's callback throws, the framework logs it and continues firing the remaining observers and the stream itself. Observers cannot break the stream.

Callbacks#

CallbackSignatureDescription
onStart(ctx) => voidThe stream opened.
onChunk(ctx, chunk, index) => voidEach chunk, with its zero-based index.
onEnd(ctx, { chunks, result }) => voidThe stream completed.
onError(ctx, err, { chunks }) => voidThe stream threw.
onAbort(ctx, { chunks }) => voidThe client navigated away or aborted.
import { defineStreamObserver, defineLoader } from 'hono-preact';

const trace = defineStreamObserver({
  onStart: (ctx) => console.log('stream:start', ctx.loader),
  onChunk: (_ctx, chunk, i) => console.log(`chunk[${i}]`, chunk),
  onEnd: (_ctx, { chunks, result }) =>
    console.log('stream:end', { chunks, result }),
  onError: (_ctx, err, { chunks }) =>
    console.error('stream:error', err, { chunks }),
  onAbort: (_ctx, { chunks }) => console.log('aborted', { chunks }),
});

export const serverLoaders = {
  default: defineLoader(
    async function* () {
      for (const row of cursorRows()) yield row;
    },
    { use: [trace] }
  ),
};

The use array#

use is a flat array. The dispatcher walks it once and partitions into middleware (server + client) and stream observers, then runs each group with the right strategy. You can mix everything in one list:

use: [requireSessionServer, requireSessionClient, rateLimit, trace];

Ordering matters for middleware: outer-to-inner is appConfig.use first, then node use (ancestors outer-first), then per-unit use, in the order each array lists them. Observers have no relative ordering: they all fire on every chunk in registration order, and one observer's slowness doesn't gate the others.

Every entry has to be one of those two. The framework checks each one as it builds the chain, and an entry it cannot classify (a wrong import, a hand-rolled object missing runs or fn) throws with the array and index that hold it. It is deliberately loud: the alternative is a malformed guard quietly sitting in the observer bucket, where nothing it does can deny.

Worked examples#

Auth gate with server + client legs#

// auth/session.ts
import {
  defineServerMiddleware,
  defineClientMiddleware,
  redirect,
} from 'hono-preact';
import { currentUser } from './session.js';

const server = defineServerMiddleware(async (ctx, next) => {
  if (!(await currentUser(ctx.c))) throw redirect('/login');
  await next();
});

const client = defineClientMiddleware(async (_ctx, next) => {
  if (
    typeof window !== 'undefined' &&
    !window.localStorage.getItem('app:authed')
  ) {
    throw redirect('/login');
  }
  await next();
});

export const requireSession = [server, client];

Timing middleware that logs duration#

import { defineServerMiddleware } from 'hono-preact';

export const timing = defineServerMiddleware(async (ctx, next) => {
  const t0 = performance.now();
  try {
    await next();
  } finally {
    const ms = (performance.now() - t0).toFixed(1);
    console.log(`[${ctx.scope}] ${ctx.c.req.path} ${ms}ms`);
  }
});

Tracing middleware around a span#

import { defineServerMiddleware } from 'hono-preact';
import { tracer } from './otel.js';

export const traced = defineServerMiddleware(async (ctx, next) => {
  await tracer.startActiveSpan(`hp:${ctx.scope}`, async (span) => {
    try {
      await next();
    } catch (err) {
      span.recordException(err);
      throw err;
    } finally {
      span.end();
    }
  });
});

Per-chunk audit observer#

import { defineStreamObserver } from 'hono-preact';

export const auditChunks = defineStreamObserver({
  onChunk: (ctx, chunk, i) => {
    auditLog.push({
      module: ctx.module,
      loader: ctx.loader,
      index: i,
      bytes: JSON.stringify(chunk).length,
    });
  },
});

Page render replacement#

// hono-preact/page is the page-scope-only subpath where `render` lives.
import { defineServerMiddleware, deny } from 'hono-preact';
import { render } from 'hono-preact/page';
import { LoginModal } from './LoginModal.js';
import { currentUser } from './session.js';

export const showLoginModal = defineServerMiddleware(async (ctx, next) => {
  if (!(await currentUser(ctx.c))) {
    // `render` is page-scope only, and a route node's `use` is dispatched at
    // loader and action scope too, so narrow first. On the RPC paths the
    // client surfaces the deny as a thrown Error rather than a modal.
    if (ctx.scope === 'page') {
      // Render an alternative component in place of the matched page.
      // The page tree is replaced; the user keeps the current URL.
      throw render(LoginModal);
    }
    throw deny('UNAUTHORIZED');
  }
  await next();
});