hono-preact
Overview
Quick Start
The Route Table
Layouts & Nesting
Adding Pages
Active Links
Server Loaders
Loading States
Reloading Data
Prefetching
Streaming
Live Loaders
Realtime Channels
Server Actions
Validation
Optimistic UI
View Transitions
Middleware
CSRF Protection
CLI
Vite Config
Project Structure
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
useSafeArea
usePresence

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 all its descendants (render + their loaders/actions).
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] });
// 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',
    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 { defineLoader, defineAction } from 'hono-preact';
import { auditLog } from '../../audit.js';

// requireSession is declared on the route node in routes.ts and
// covers both render and RPC paths for this entire subtree.
export const serverLoaders = {
  default: defineLoader(async () => listAdminRows()),
};

export const serverActions = {
  promote: defineAction(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, covering both the render path and the RPC paths (loaders and actions). 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.

// 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 request 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.

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.

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';

const adminOnly = defineServerMiddleware<'page'>(async (ctx, next) => {
  const user = await currentUser(ctx.c);
  if (!user) throw redirect('/login');
  if (!user.isAdmin) throw render(NotAuthorizedPage);
  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.

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.

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, redirect } from 'hono-preact';
import { render } from 'hono-preact/page';
import { LoginModal } from './LoginModal.js';
import { currentUser } from './session.js';

export const showLoginModal = defineServerMiddleware<'page'>(
  async (ctx, next) => {
    if (!(await currentUser(ctx.c))) {
      // Render an alternative component in place of the matched page.
      // The page tree is replaced; the user keeps the current URL.
      throw render(LoginModal);
    }
    await next();
  }
);