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 its descendants: renders, plus route-bound loaders and actions (serverRoute(r)). A bare defineLoader / defineAction RPC is route-independent (see below). |
| 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',
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.
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).
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:
| 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();
}
);