Hono on the Edge: Routing, Middleware, JWT and Cloudflare Workers Setup
Key takeaways
Hono is a tiny, fast web framework built on Web Standards — same code runs on Cloudflare Workers, Deno, Bun, and Node.js. This guide covers everything from routing to production JWT auth and edge database patterns.
Why Hono?
Hono is a lightweight, ultra-fast web framework built on Web Standards (Request/Response, Fetch API). It runs identically on:
- Cloudflare Workers — edge, zero cold start
- Deno / Deno Deploy
- Bun
- Node.js (via adapter)
- AWS Lambda, Vercel Edge
The Radix Tree router delivers consistent performance at any scale. The entire framework is ~14KB gzipped.
The “built on Web Standards” framing is worth taking seriously rather than treating as marketing language — Express’s req/res objects are Node.js-specific abstractions invented before the web platform itself standardized Request/Response/fetch, so Express code is fundamentally coupled to running inside a Node.js process. Hono’s handlers work directly with the same Request/Response objects a browser’s fetch() API uses, which is precisely what lets identical route code run unmodified on Cloudflare Workers’ V8 isolates, Deno, Bun, or Node — there’s no framework-specific abstraction layer standing between your code and the actual platform primitives that needs a different adapter per runtime. That portability is the whole value proposition; the speed and small bundle size are real, but they’re a consequence of the same lean design, not really a separate feature.
Quick Start
# Cloudflare Workers project
npm create cloudflare@latest my-api -- --template hono
# Bun project
bun create hono my-api
# Node.js
npm install hono @hono/node-server
Routing
import { Hono } from 'hono'
const app = new Hono()
// Basic routes
app.get('/', (c) => c.text('Hello Hono!'))
app.post('/users', (c) => c.json({ created: true }))
app.put('/users/:id', (c) => c.json({ id: c.req.param('id') }))
app.delete('/users/:id', (c) => c.json({ deleted: true }))
// Route params
app.get('/users/:id', (c) => {
const id = c.req.param('id')
return c.json({ id })
})
// Query params
app.get('/search', (c) => {
const q = c.req.query('q')
const page = c.req.query('page') ?? '1'
return c.json({ q, page })
})
// Multiple params
app.get('/posts/:year/:month', (c) => {
const { year, month } = c.req.param()
return c.json({ year, month })
})
export default app
export default app at the bottom, rather than a .listen() call, is the detail that most concretely captures the Web Standards design covered above — there’s no app.listen(port) here at all, because Hono’s app is really just an object with a fetch method matching the platform’s own fetch handler signature, and each runtime’s own entry point (a Workers export default { fetch: app.fetch }, or @hono/node-server’s serve() for Node, covered later in this guide) is what actually wires that fetch method up to real incoming requests. This is exactly why the routing code above needs zero changes to move between runtimes — it never assumes anything about how requests arrive, only that it’ll eventually be handed a Request and asked for a Response.
Route groups
// Group routes under a prefix
const api = new Hono().basePath('/api')
const users = new Hono()
users.get('/', (c) => c.json({ users: [] }))
users.get('/:id', (c) => c.json({ id: c.req.param('id') }))
users.post('/', async (c) => {
const body = await c.req.json()
return c.json({ created: body }, 201)
})
api.route('/users', users)
const app = new Hono()
app.route('/', api) // GET /api/users, GET /api/users/:id
export default app
api.route('/users', users) composing a smaller, independently-defined Hono instance into a larger one is the pattern worth internalizing for any API beyond a handful of routes — each resource (users, posts, comments) gets its own file with its own Hono() instance and its own set of routes defined relative to that resource’s root, and the top-level app just mounts each one at the right prefix. This scales far better than one flat file of routes as an API grows, and it’s a genuinely close parallel to Express’s Router() — the concept isn’t new to Hono, but the type-safety (each route group’s params/context types flow through correctly once mounted) is tighter than what Express’s loosely-typed Router gives you out of the box.
Middleware
Hono middleware wraps request handling — logging, auth, validation.
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { prettyJSON } from 'hono/pretty-json'
const app = new Hono()
// Built-in middleware
app.use('*', logger())
app.use('*', prettyJSON())
app.use('/api/*', cors({
origin: ['https://myapp.com', 'https://staging.myapp.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
}))
// Applying cors() only to '/api/*' rather than '*' is a deliberate scoping
// choice worth noticing: CORS headers matter for routes a browser-based
// frontend on a different origin actually calls via fetch/XHR, but adding
// them everywhere (say, to a server-rendered HTML route) is unnecessary
// overhead and can occasionally interact confusingly with routes that were
// never meant to be called cross-origin in the first place.
// Custom middleware
app.use('*', async (c, next) => {
const start = Date.now()
await next()
const elapsed = Date.now() - start
c.res.headers.set('X-Response-Time', `${elapsed}ms`)
})
// The position of `await next()` inside a custom middleware is what
// separates "before" logic from "after" logic — code before `next()` runs
// on the way in (before the route handler executes), and code after it
// runs on the way back out, once the route handler and every middleware
// further down the chain has already finished. This timing middleware
// only works because `start` is captured before `next()` and `elapsed` is
// computed after it — moving that timestamp capture to the wrong side of
// `await next()` would silently measure the wrong thing.
// Route-specific middleware
const authMiddleware = async (c: Context, next: Next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
// validate token...
await next()
}
app.get('/protected', authMiddleware, (c) => c.json({ data: 'secret' }))
The distinction worth drawing between app.use('*', ...) and passing middleware directly into a route definition like app.get('/protected', authMiddleware, ...) is scope: global middleware (registered via .use()) runs on every matching request regardless of which route ultimately handles it, while middleware passed inline to a specific route only ever runs for that one route. Auth is a common case where you want the narrower scope deliberately — applying authMiddleware globally would require every single route, including genuinely public ones, to somehow opt out, which is a much easier default to get wrong (an accidentally-public authenticated endpoint) than opting individual protected routes in explicitly.
Context — passing data between middleware
import { createMiddleware } from 'hono/factory'
// Type-safe context variables
type Variables = {
userId: string
userRole: 'admin' | 'user'
}
const app = new Hono<{ Variables: Variables }>()
const auth = createMiddleware<{ Variables: Variables }>(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
// Decode token (simplified)
const payload = decodeJWT(token)
c.set('userId', payload.sub)
c.set('userRole', payload.role)
await next()
})
app.get('/profile', auth, (c) => {
const userId = c.get('userId') // type: string
const role = c.get('userRole') // type: 'admin' | 'user'
return c.json({ userId, role })
})
The Hono<{ Variables: Variables }> generic is what makes c.get('userId') return a real, checked string type instead of any — without declaring that shape up front, c.set/c.get would accept and return arbitrary untyped values, and a typo like c.get('userld') (an l instead of 1, genuinely easy to miss visually) would compile fine and fail silently at runtime by returning undefined. This is a small but real advantage over Express’s req.userId = ... pattern (attaching arbitrary properties to the request object), which TypeScript has no native way to type-check at all without extending Express’s own type definitions manually — Hono’s typed context variables solve the same problem with the type system doing the checking for you.
JWT Authentication
npm install hono # jwt middleware is built-in
import { jwt } from 'hono/jwt'
import { sign, verify } from 'hono/jwt'
const JWT_SECRET = process.env.JWT_SECRET!
// Protect routes
app.use('/api/*', jwt({ secret: JWT_SECRET }))
// Login — issue token
app.post('/auth/login', async (c) => {
const { email, password } = await c.req.json()
const user = await db.users.findUnique({ where: { email } })
if (!user || !await bcrypt.compare(password, user.passwordHash)) {
return c.json({ error: 'Invalid credentials' }, 401)
}
const token = await sign(
{ sub: user.id, role: user.role, exp: Math.floor(Date.now() / 1000) + 3600 },
JWT_SECRET
)
return c.json({ token })
})
// The `exp` claim here isn't optional bookkeeping — it's what makes the
// token expire at all. A JWT with no exp claim (or one set to a far-future
// value "for convenience") stays valid forever once issued, which means a
// leaked token gives an attacker permanent access rather than a bounded
// window — I've seen a codebase ship exactly this bug, quietly extending
// exp during a "fix login timeout" change, and it went unnoticed for
// months since the tokens still worked correctly for legitimate users;
// the actual risk only shows up if a token ever leaks, which by
// definition isn't something a normal test run would ever catch.
// Access JWT payload
app.get('/api/me', jwt({ secret: JWT_SECRET }), (c) => {
const payload = c.get('jwtPayload')
return c.json({ userId: payload.sub, role: payload.role })
})
Notice jwt({ secret: JWT_SECRET }) gets applied twice here — once broadly via app.use('/api/*', ...) and again inline on /api/me — which is redundant as written (the global middleware already covers /api/me since it matches the /api/* pattern) but harmless; it’s worth being aware that Hono’s jwt middleware, once it succeeds, is what populates c.get('jwtPayload') for any handler downstream of it, so the payload access on the last line only works because the middleware already ran and verified the token before this handler executes at all — a request without a valid token never reaches this code, since the middleware returns its own 401 response first.
Request Validation
Hono’s Zod validator provides type-safe request parsing:
npm install @hono/zod-validator zod
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
role: z.enum(['admin', 'user']).default('user'),
})
const updateUserSchema = createUserSchema.partial()
// .partial() makes every field on the schema optional without redefining
// them — exactly the shape a PATCH/PUT update endpoint needs (a client
// updating only their email shouldn't be forced to resend name and role
// too), while reusing createUserSchema's actual validation rules (the
// email format check, the length bounds) rather than duplicating them in
// a second, separately-maintained schema that could drift out of sync.
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
})
app.post(
'/users',
zValidator('json', createUserSchema),
async (c) => {
const body = c.req.valid('json') // fully typed
const user = await db.users.create({ data: body })
return c.json(user, 201)
}
)
// c.req.valid('json') is the payoff for wiring up zValidator: the request
// body has already been parsed and validated against createUserSchema by
// the time this handler runs, so the returned value is genuinely typed as
// the schema's inferred TypeScript type — no `as` cast, no separate
// runtime check needed inside the handler. If validation had failed,
// zValidator returns its own 400 response automatically and this handler
// body never executes at all, which is what makes every route using it
// able to assume its input is already well-formed.
app.get(
'/users',
zValidator('query', querySchema),
async (c) => {
const { page, limit, search } = c.req.valid('query')
const users = await db.users.findMany({
skip: (page - 1) * limit,
take: limit,
where: search ? { name: { contains: search } } : undefined,
})
return c.json({ users, page, limit })
}
)
z.coerce.number() on page/limit in querySchema matters for a reason easy to overlook: query string values arrive as plain strings (?page=2 gives you the literal string "2", never the number 2), and without coerce, z.number() would reject every query param outright since a string is never a valid number by Zod’s normal rules. coerce tells Zod to attempt the string-to-number conversion first and then validate the result, which is exactly the right behavior for query params and form data, but worth knowing not to reach for reflexively on json-body validation, where values usually already arrive as their correct native JSON types.
Cloudflare Workers — Full Setup
// src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { jwt } from 'hono/jwt'
// Cloudflare Workers bindings type
type Bindings = {
DB: D1Database // D1 SQLite
KV: KVNamespace // KV store
JWT_SECRET: string // Secret from wrangler.toml
}
const app = new Hono<{ Bindings: Bindings }>()
app.use('*', cors())
// This is where the "no traditional TCP database connections" FAQ point
// above becomes concrete: c.env.DB/c.env.KV aren't imported modules or
// connection objects you instantiate yourself, they're bindings Cloudflare
// injects into the Worker's execution context at request time, configured
// declaratively in wrangler.toml below rather than via a connection
// string in code. This is a genuinely different mental model from a
// traditional Node server — there's no persistent process holding a pool
// of open connections, since a Worker's isolate can be spun up, handle a
// single request, and torn down, which is also why D1's SQLite-over-HTTP
// approach exists instead of raw TCP: a V8 isolate has no access to raw
// TCP sockets at all, only Web-standard APIs like fetch.
// D1 database query
app.get('/posts', async (c) => {
const { results } = await c.env.DB.prepare(
'SELECT * FROM posts WHERE published = 1 ORDER BY created_at DESC LIMIT 20'
).all()
return c.json({ posts: results })
})
app.get('/posts/:id', async (c) => {
const id = c.req.param('id')
const post = await c.env.DB.prepare(
'SELECT * FROM posts WHERE id = ?'
).bind(id).first()
if (!post) return c.json({ error: 'Not found' }, 404)
return c.json(post)
})
app.post('/posts', jwt({ secret: (c) => c.env.JWT_SECRET }), async (c) => {
const body = await c.req.json()
const { success } = await c.env.DB.prepare(
'INSERT INTO posts (title, content, created_at) VALUES (?, ?, ?)'
).bind(body.title, body.content, new Date().toISOString()).run()
return c.json({ success }, 201)
})
// `jwt({ secret: (c) => c.env.JWT_SECRET })` passing a function instead of
// a plain string is specific to this Workers-bindings context: the secret
// only becomes available as part of the request's execution context
// (c.env), not as a static value the module can read at import time the
// way `process.env.JWT_SECRET` works in Node — so the middleware needs a
// function it can call per-request to resolve the secret, rather than a
// value baked in once when the file loads.
// KV cache
app.get('/config', async (c) => {
const cached = await c.env.KV.get('site-config', 'json')
if (cached) return c.json(cached)
const config = await fetchConfig()
await c.env.KV.put('site-config', JSON.stringify(config), { expirationTtl: 3600 })
return c.json(config)
})
export default app
KV here is deliberately eventually-consistent, not a strongly-consistent cache — writes can take a short while (typically seconds, occasionally longer) to propagate globally across Cloudflare’s edge locations, which is a real tradeoff worth understanding before reaching for it: fine for config that changes rarely and where a stale read for a few seconds after an update is harmless, genuinely wrong for anything needing read-your-writes consistency (a user immediately seeing their own just-saved change). expirationTtl: 3600 is the mechanism that keeps a cache like this from serving indefinitely-stale data even if nothing ever explicitly invalidates it — worth setting deliberately based on how stale a value is acceptable to be, not copied as a default without thinking about what it’s actually caching.
# wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2024-01-01"
[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "your-database-id"
[[kv_namespaces]]
binding = "KV"
id = "your-kv-id"
[vars]
JWT_SECRET = "your-secret" # use wrangler secret put JWT_SECRET for production
That comment on the last line is worth heeding rather than skimming past — [vars] values in wrangler.toml are plaintext and typically committed to version control along with the rest of the config, which is fine for genuinely non-sensitive settings but exactly wrong for a JWT signing secret. wrangler secret put JWT_SECRET stores the value encrypted server-side on Cloudflare’s infrastructure, injected into c.env at runtime the same way, but never written to disk in the repo or visible via wrangler.toml at all — the same never-commit-a-secret principle covered in the dotenv guide elsewhere on this site, just with Cloudflare’s own mechanism for it instead of a .env file.
# Deploy
wrangler deploy
# Run locally
wrangler dev
Worth knowing wrangler dev doesn’t just run your code locally in a generic Node sandbox — it actually runs it inside workerd, Cloudflare’s real open-source Workers runtime, which is what makes local testing meaningfully trustworthy for catching Workers-specific runtime differences (APIs Node has that Workers doesn’t, and vice versa) before deploy, rather than only discovering a runtime incompatibility once code is live in production.
Error Handling
import { HTTPException } from 'hono/http-exception'
// Throw structured errors anywhere
app.get('/users/:id', async (c) => {
const user = await db.users.findUnique({ where: { id: c.req.param('id') } })
if (!user) {
throw new HTTPException(404, { message: 'User not found' })
}
return c.json(user)
})
// Global error handler
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status)
}
console.error('Unhandled error:', err)
return c.json({ error: 'Internal server error' }, 500)
})
// 404 handler
app.notFound((c) => c.json({ error: 'Route not found' }, 404))
app.onError catching both HTTPException and generic Error objects in one place is what gives an API consistent error responses without every single route handler needing its own try/catch and its own ad-hoc error-shaping logic — a route just throws (an HTTPException for an expected, well-understood failure, or lets an unexpected error propagate naturally) and this one handler decides how it gets serialized to the client. The console.error on the unhandled-error branch matters for a reason worth being explicit about: an unexpected error means something genuinely went wrong that nobody anticipated, and swallowing it into a generic “Internal server error” response with no server-side logging would make that class of bug essentially invisible until a user complains — the log line is what actually lets you find and fix it.
Streaming Responses
Ideal for AI/LLM output:
import { streamText } from 'hono/streaming'
app.post('/chat', async (c) => {
const { messages } = await c.req.json()
return streamText(c, async (stream) => {
const response = await openai.chat.completions.create({
model: 'gpt-4',
messages,
stream: true,
})
for await (const chunk of response) {
const content = chunk.choices[0]?.delta?.content
if (content) {
await stream.write(`data: ${JSON.stringify({ content })}\n\n`)
}
}
await stream.write('data: [DONE]\n\n')
})
})
Without streaming, a client calling an LLM-backed endpoint would have to wait for the entire response to finish generating server-side before receiving anything at all — for a multi-second completion, that’s a multi-second blank wait with no feedback, versus streaming’s token-by-token arrival that lets a UI render text progressively as it’s generated, which is the difference between a chat interface that feels responsive and one that feels frozen. The data: ...\n\n framing here is the Server-Sent Events (SSE) wire format specifically — that exact double-newline-terminated shape is what lets a browser’s EventSource API (or a manually-parsed fetch stream reader) correctly split the stream back into discrete events on the receiving end, and it’s why [DONE] gets sent as its own explicit sentinel event rather than just closing the stream silently — the client needs an unambiguous signal that generation has genuinely finished versus the connection merely dropping.
Node.js Deployment
// server.ts
import { serve } from '@hono/node-server'
import app from './app'
serve({
fetch: app.fetch,
port: 3000,
}, (info) => {
console.log(`Server running on http://localhost:${info.port}`)
})
@hono/node-server’s serve() is the adapter layer that bridges the gap this guide has been building toward — it takes the same app.fetch handler that runs directly on Cloudflare’s Workers runtime with no wrapper needed at all, and wires it up to Node’s own http module, translating between Node’s callback-based request/response model and the Web Standard Request/Response objects Hono’s routes actually work with. It’s worth being clear that this adapter is what makes Node.js support possible, not a sign that Hono is secretly Node-first with edge support bolted on — the routing/middleware/validation code itself never touches Node APIs directly, which is precisely why the same app object works here unmodified.
Hono vs Express vs Fastify
| Hono | Express | Fastify | |
|---|---|---|---|
| Runtime | Any (edge + Node) | Node.js | Node.js |
| Speed | Fastest | Slowest | Fast |
| Bundle size | ~14KB | ~57KB | ~180KB |
| TypeScript | First-class | Community types | Built-in |
| Edge support | Native | Via adapter | Limited |
| Ecosystem | Growing | Mature | Mature |
The ecosystem row is worth weighing more heavily than the benchmark numbers above it — I’ve migrated a small internal API from Express to Hono specifically to run it on Workers, and the routing/middleware port itself was genuinely painless given how similar the mental model is. The friction showed up entirely in the surrounding tooling: an Express-specific logging middleware, a session library assuming Node’s http.IncomingMessage, a metrics agent that hooked into Node’s process lifecycle — none of which have direct Workers-compatible equivalents, since the Workers runtime simply doesn’t have some of the Node APIs those tools depend on. Raw framework speed rarely ends up being the deciding factor in practice; whether your specific dependencies have edge-compatible (or Web-Standard-based) equivalents usually is, and it’s worth auditing that before committing to a migration, not after.
Frequently Asked Questions (FAQ)
Q. If I move JWT_SECRET to wrangler secret put, how do I provide it during wrangler dev?
A. Secrets stored with wrangler secret put live on Cloudflare and are not used by wrangler dev running locally. Put local-only values in a .dev.vars file in the project root, using KEY=value lines like a .env file, and add it to .gitignore. wrangler dev loads those values into c.env the same way the deployed Worker receives its secrets, so the jwt({ secret: (c) => c.env.JWT_SECRET }) code works unchanged in both environments.
Related Articles
- Edge Functions and Serverless: Cloudflare Workers vs Lambda@Edge, Cold Starts and Trade-offs
- Prisma ORM in TypeScript
- Docker & Kubernetes Beginners Guide