Writing Your Own Hono Middleware
Build Hono middleware with typed context variables, request timing, early exits, and path scoping. See how next() and response order work.
A Hono middleware is an async function that takes (c, next). It can end in one of two ways: it awaits next() and returns nothing, which passes the request on, or it returns a Response and the request stops there.
logger() and cors() are called as factories, so that signature stays out of sight until you write your own. The first one you write usually works. The trouble starts a step later, when a handler reads a value the middleware set and TypeScript cannot tell you what type it is.
This article builds middleware from the signature up: the two phases and the order they run in, a request timer that needs both phases, typed context variables that keep their types once the middleware moves into its own file, early exit, and path scoping. It assumes you have routes running already, as covered in getting started with Hono. Coming from Express, the framework comparison and the porting notes cover the ground this article starts after. Examples target the Hono 4.13.x line published on npm.
Key Takeaways
- A Hono middleware is
(c, next): code beforeawait next()runs on the way in, code after it runs on the way out. - Middleware runs in registration order inbound and reverse registration order outbound, so the first registered is the first to see the request and the last to see the response.
createMiddleware()fromhono/factorywith aVariablesgeneric keepscandnexttyped when middleware moves into its own file.- Returning a
Responsewithout callingnext()ends the request: nothing registered after it runs. - Hono catches errors from handlers and middleware and routes them to
onError()or a 500, sonext()never throws.
The Signature and the Two Ways Middleware Ends
Middleware takes the context and the next function, and the Hono middleware guide allows it only two endings. It can pass control further down the chain by awaiting next() and returning nothing at all, or it can stop the request where it stands by handing back a Response of its own. The other primitive is the handler, which always produces a Response, and a single request only ever reaches one of them.
import { Hono } from 'hono'
const app = new Hono()
app.use(async (c, next) => {
console.log(`[${c.req.method}] ${c.req.url}`)
await next()
})
app.get('/', (c) => c.text('Hello!'))
export default app
Forget the await and the function keeps its meaning but loses its ordering guarantee, so always write await next().
The Onion: In What Order Does Hono Middleware Run?
Code placed before await next() runs on the way in, and code placed after it runs on the way out, which is why a single function can start a timer before the handler and write the elapsed value to a response header after it. Hono’s concepts documentation pictures the arrangement as an onion, with every layer wrapping the handler and taking a turn on each side of it. Middleware runs in registration order on the way in and in reverse registration order on the way out, so the first middleware you register is the first to see the request and the last to see the response.
import { Hono } from 'hono'
const app = new Hono()
app.use(async (_, next) => {
console.log('timing start')
await next()
console.log('timing end')
})
app.use(async (_, next) => {
console.log(' auth start')
await next()
console.log(' auth end')
})
app.use(async (_, next) => {
console.log(' flags start')
await next()
console.log(' flags end')
})
app.get('/', (c) => {
console.log(' handler')
return c.text('Hello!')
})
export default app
One request produces:
timing start
auth start
flags start
handler
flags end
auth end
timing end
The indentation is baked into the log strings, not produced by console.log. It is there because the nesting is the point: every middleware’s outbound half is wrapped by the outbound half of everything registered before it.
A Request Timer, Written Across Both Phases
Hono ships a Server-Timing middleware that emits Server-Timing headers from hono/timing, and it is the right choice when that format suits you. A hand-rolled timer is still the clearest demonstration of two-phase execution, because the value it reports exists in neither phase alone.
import { createMiddleware } from 'hono/factory'
export const responseTime = createMiddleware(async (c, next) => {
const start = Date.now()
await next()
c.header('X-Response-Time', `${Date.now() - start}ms`)
})
After await next() a response already exists, so c.header() writes straight into that response’s headers rather than into prepared headers. The same middleware page carries a caveat for Cloudflare Workers: a timer there is pinned to the last I/O rather than to real elapsed time, so the figures can mislead.
How Do You Pass Typed Values Down the Chain?
Anything written with c.set() belongs to one request and goes away with it, so a value cannot be carried over to the next request or shared between them. Downstream code reads it back with c.get('key') or c.var.key, both covered on the Context API page. Set inline, the values arrive downstream untyped:
app.use(async (c, next) => {
c.set('requestId', crypto.randomUUID())
await next()
})
app.get('/', (c) => c.json({ id: c.var.requestId })) // no type information
createMiddleware() from hono/factory keeps the types on c and next intact when a middleware moves into its own file, and passing it a Variables generic makes every value the middleware sets type-safe for the handlers that follow.
// middleware/request-id.ts
import { createMiddleware } from 'hono/factory'
export const requestId = createMiddleware<{
Variables: { requestId: string }
}>(async (c, next) => {
c.set('requestId', crypto.randomUUID())
await next()
})
// index.ts
import { Hono } from 'hono'
import { requestId } from './middleware/request-id'
const app = new Hono().use(requestId).get('/', (c) => {
return c.json({ id: c.var.requestId }) // string
})
export default app
Three mechanisms exist for typing context variables. Reach for the middle row by default.
| Mechanism | Where the type is visible | Cost |
|---|---|---|
new Hono<{ Variables }>() | Every route on that app instance | Hand-maintained combined type, duplicated at each declaration site |
createMiddleware<{ Variables }>() | Routes downstream of that middleware | One generic per middleware |
ContextVariableMap augmentation | Every context in the application | Types routes the middleware never ran on |
Augment ContextVariableMap and the type lands on every context in the application, whether or not the middleware that sets the value ever ran on that route. Where it did not run, c.get() still looks typed while the value is missing at runtime. The warning in the Context API docs makes the point with a pair of routes, one that uses the middleware and one that does not. Scope the type to the middleware instead.
How Do You Short-Circuit Without Calling next()?
Return a Response and skip next(), and the request ends there: no later middleware, no handler.
import { createMiddleware } from 'hono/factory'
export const apiKeyAuth = createMiddleware(async (c, next) => {
if (c.req.header('X-API-Key') !== 'expected-key') {
return c.text('Unauthorized', 401)
}
await next()
})
Everything registered after this middleware never runs, including the outbound half of any middleware that would have wrapped it. The outbound halves of middleware registered before it still run, so a timer registered first still stamps the 401.
How Do You Scope Hono Middleware to a Path?
Pass a path pattern as the first argument to app.use() to restrict a middleware to matching routes, and remember that order is registration order.
app.use(responseTime) // every route
app.use('/api/*', apiKeyAuth) // only /api/**
app.get('/api/orders', (c) => c.json([]))
responseTime is registered first, so it wraps apiKeyAuth and measures rejected requests too. Swap the two lines and the timer stops seeing 401s.
Two Behaviours That Catch People Out
An error thrown anywhere in the chain, by a handler or by any middleware, is caught by Hono itself: it goes to app.onError() where you have one, and otherwise comes back as a 500. That is why next() never throws, and why wrapping it in try/catch buys you nothing. The error is still reachable on c.error after await next() if you want to log it.
Types add up along a .use() chain, so a handler sitting after two typed middleware sees the variables from both. Each .use() hands back an instance whose type already includes what came before it, which is why the middleware guide says most applications never need to write out a combined Env type in advance.
Where to Go From Here
Custom middleware is one function shape with two halves and one rule about how it ends. Get the Variables generic on it from the first line you write, keep each one in its own module, and registration order does the rest. Take the timer above, move it into middleware/, and add a config argument by wrapping createMiddleware() in a plain function that returns it: that is the whole pattern for a parameterised middleware.
FAQs
How do I run a middleware on one route only instead of every route?
Register it with the route method rather than a bare app.use(). Hono lets you attach middleware through app.HTTP_METHOD, so app.post('/posts/*', basicAuth()) applies only to POST requests on that path, and passing the middleware as an argument before the handler, as in app.get('/echo', echoMiddleware, handler), scopes it to that single route without touching the rest of the application.
Does middleware apply to routes that were registered before it?
Register middleware above the handlers it should wrap. Hono executes handlers and middleware in registration order, and the routing documentation puts the rule plainly: anything you want to run before a handler has to be registered ahead of it. A route declared before the app.use() call is matched first, so the middleware added later is not reliably part of that route's chain.
What is the difference between Bindings and Variables in the Env generic?
Bindings describe platform resources and environment values the runtime injects, read through c.env, such as a D1 database or a secret on Cloudflare Workers. Variables describe the per-request values your own code writes with c.set() and reads with c.get() or c.var. Both sit on the same Env object passed as new Hono<{ Bindings: ...; Variables: ... }>().
Can I read a context variable outside a handler or middleware?
Yes, with the built-in Context Storage middleware. Register app.use(contextStorage()) from 'hono/context-storage', then call getContext() inside any function to reach the current request's context, including c.var and, on Cloudflare Workers, bindings on c.env. tryGetContext() behaves the same way but hands back undefined where getContext() would throw, which suits code that may run outside a request.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k