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:
| Layer | Where you declare it | What it wraps |
|---|---|---|
| App | defineApp({ use }) default-exported from src/app-config.ts | Every page render, every loader, every action. |
| Page | use on a route node in src/routes.ts | The matched node and all its descendants (render + their loaders/actions). |
| Unit | defineLoader(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:
| Outcome | Where it makes sense | Result |
|---|---|---|
redirect(to) | any scope | HTTP redirect on SSR; route(to) on the client; { __outcome: 'redirect', to } envelope on loader/action RPC |
deny(status, message) | any scope | HTTP response at status with message; on RPC paths the client surfaces message as the thrown Error |
render(Component) | page scope only | Renders <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#
| Callback | Signature | Description |
|---|---|---|
onStart | (ctx) => void | The stream opened. |
onChunk | (ctx, chunk, index) => void | Each chunk, with its zero-based index. |
onEnd | (ctx, { chunks, result }) => void | The stream completed. |
onError | (ctx, err, { chunks }) => void | The stream threw. |
onAbort | (ctx, { chunks }) => void | The 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();
}
);