Middleware
Middleware wraps loader, action, and page calls in an outer/inner chain. Each middleware receives a ctx and a next continuation; the framework calls them in order, then unwinds. Throw a redirect, deny, or render outcome to short-circuit. Use middleware for auth gates, request-scoped setup/teardown, tracing, timing, and request logging.
Page bindings, loaders, and actions all accept the same use array, and the framework partitions server- vs client-bound members by their runs tag.
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 | definePage(View, { use }) and export const pageUse (.server.ts) | The matched page tree and its 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] });
// apps/site/src/pages/admin/page.tsx
import { definePage } from 'hono-preact';
import { requireSession } from '../../auth/session.js';
// The `use` binding on definePage drives the page React tree:
// PageMiddlewareHost runs server members during SSR prerender and
// client members during intra-app navigation.
export default definePage(AdminView, { use: requireSession });
// apps/site/src/pages/admin/page.server.ts
import { defineLoader, defineAction } from 'hono-preact';
import { auditLog } from '../../audit.js';
import { requireSession } from '../../auth/session.js';
// `pageUse` is a recognized named export on .server.* modules. The
// loader and action handlers read it at runtime and apply the same
// middleware around every loader and action defined in this file.
// Reference the same array you passed to definePage so the page tree
// and the RPC paths gate identically. (`loaderUse` and `actionUse`
// are reserved alongside it; today only `pageUse` is wired.)
export const pageUse = requireSession;
export const serverLoaders = {
default: defineLoader(async () => listAdminRows()),
};
export const serverActions = {
promote: defineAction(async (ctx, payload) => promoteUser(payload.id), {
use: [auditLog],
}),
};
Why two declarations for the page layer
definePage({ use }) and pageUse cover two different dispatch paths for the same page:
definePage({ use })is consumed byPageMiddlewareHostinside the page's React tree. It runs during SSR prerender and during intra-app navigation. This is the path aredirectoutcome takes when a logged-out user clicks a link to the page.pageUseon.server.*is consumed byloadersHandlerand the page POST handler directly, around every loader RPC and action POST that belongs to this page. The page React tree never renders for those calls, so the page-tsxusebinding wouldn't fire.
Export the same array from both surfaces so a user can't hit a loader behind a back-door RPC call when the page-tsx binding would have blocked them.
Chain order
Outer-to-inner the chain is appConfig.use → pageUse → 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
When routes nest under a layout, the page layer composes every ancestor .server.* module's pageUse outermost first, then the matched route's own. So with a tree like:
// src/routes.ts
defineRoutes([
{
path: '/admin',
layout: () => import('./admin/layout.js'),
server: () => import('./admin/layout.server.js'), // exports pageUse = [adminGate]
children: [
{
path: 'users/:id',
view: () => import('./admin/users.js'),
server: () => import('./admin/users.server.js'), // exports pageUse = [auditLog]
},
],
},
]);
a request to /admin/users/42 runs:
root:before // appConfig.use
admin:before // /admin layout's pageUse: adminGate
audit:before // /admin/users/:id leaf's pageUse: auditLog
unit:before // defineLoader({ use })
inner
unit:after
audit:after
admin:after
root:after
You don't have to repeat the ancestor's gate in every leaf. The composition is keyed by route-pattern prefix: any .server.* module whose route is an ancestor of the matched route contributes its pageUse to the chain. Path parameters at the same position are treated as structurally equivalent for prefix matching (:projectId in the layout aligns with :projectId in the leaf).
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 pageUse, 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();
}
);