Hono: Web Framework That Actually Runs Everywhere (Edge, Bun, Deno)
I had an Express API running on a $20/month VPS that handled 500 requests per minute. When Cloudflare Workers offered 100K requests/day for free, I wanted in. But Express doesn't run on Workers — no Node.js runtime available. Found Hono, which promised Express-like syntax on any runtime. After porting three APIs to it, here's what I learned.
Why Not Express Anymore
Express is 14 years old. It works, but it's tied to Node.js. In 2026, that's a problem. I need my APIs to run on:
- Cloudflare Workers — for edge deployment, 50ms cold starts
- Bun — for local dev, 3x faster than Node.js
- AWS Lambda — for existing infra
- Deno Deploy — for some clients who prefer it
Hono's selling point: the same code runs on all of them. Not "similar code" — the same file. The framework abstracts away the runtime differences. Your routes, middleware, and helpers are portable.
Quick Start on Cloudflare Workers
# Create Hono project for Cloudflare Workers
npm create hono@latest my-api
# Choose "cloudflare-workers" template
cd my-api
npm install
npm run dev # Local dev with Wrangler
npm run deploy # Deploy to Cloudflare
The generated src/index.ts is minimal:
import { Hono } from "hono"
const app = new Hono()
app.get("/", (c) => c.json({ message: "Hello from the edge!" }))
// Route with path params
app.get("/users/:id", (c) => {
const id = c.req.param("id")
return c.json({ userId: id })
})
// POST with JSON body
app.post("/users", async (c) => {
const body = await c.req.json()
// Validate and save...
return c.json({ created: true, user: body }, 201)
})
export default app
That's it. The c object (Context) replaces Express's req/res pair. c.req for request data, c.json() for responses. If you know Express, you know Hono.
Problem
My API had CORS middleware and auth middleware. The auth middleware was rejecting preflight OPTIONS requests because it ran before the CORS middleware could set headers. Browsers got 401 on preflight, which meant no actual requests ever went through.
What I Tried
I tried adding cors() inside the auth middleware for OPTIONS requests. That worked for CORS but created a mess of conditional logic that was hard to maintain.
Actual Fix
Middleware in Hono runs in the order you add it. CORS must come before auth, and auth must handle the OPTIONS case gracefully:
import { Hono, createMiddleware } from "hono/factory"
import { cors } from "hono/cors"
import { bearerAuth } from "hono/bearer-auth"
const app = new Hono()
// 1. CORS first — handles all preflight requests
app.use("/api/*", cors({
origin: ["https://myapp.com"],
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowHeaders: ["Content-Type", "Authorization"],
}))
// 2. Auth second — only runs after CORS headers are set
app.use("/api/*", async (c, next) => {
// Skip auth for health check
if (c.req.path === "/api/health") return next()
const token = c.req.header("Authorization")
if (!token || !validateToken(token)) {
return c.json({ error: "Unauthorized" }, 401)
}
await next()
})
// 3. Routes last
app.get("/api/users", async (c) => {
const users = await getUsers()
return c.json(users)
})
Building a Real API with Middleware
Here's a real middleware stack I use in production. It handles logging, auth, rate limiting, and error handling:
import { Hono } from "hono"
import { logger } from "hono/logger"
import { prettyJSON } from "hono/pretty-json"
import { HTTPException } from "hono/http-exception"
type Bindings = {
DB: D1Database
KV: KVNamespace
JWT_SECRET: string
}
type Variables = {
userId: string
userRole: "admin" | "user"
}
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
// Logging
app.use(logger())
// Custom auth middleware — sets userId for downstream handlers
app.use("/api/*", async (c, next) => {
const token = c.req.header("Authorization")?.replace("Bearer ", "")
if (!token) throw new HTTPException(401, { message: "Missing token" })
try {
const payload = await verifyJWT(token, c.env.JWT_SECRET)
c.set("userId", payload.sub)
c.set("userRole", payload.role)
await next()
} catch {
throw new HTTPException(401, { message: "Invalid token" })
}
})
// Rate limiting with KV
app.use("/api/*", async (c, next) => {
const key = `rate:${c.get("userId")}`
const count = parseInt(await c.env.KV.get(key) || "0")
if (count > 100) {
throw new HTTPException(429, { message: "Rate limit exceeded" })
}
await c.env.KV.put(key, String(count + 1), { expirationTtl: 60 })
await next()
})
// 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)
})
// Routes with typed context
app.get("/api/profile", async (c) => {
const userId = c.get("userId") // TypeScript knows this is a string
const profile = await c.env.DB.prepare(
"SELECT * FROM users WHERE id = ?"
).bind(userId).first()
return c.json(profile)
})
export default app
Problem
Hono's RPC feature lets you share types between server and client automatically. But when I added route-specific middleware (like admin-only checks on certain routes), the client lost type inference for those routes — the response type became unknown.
What I Tried
Tried manually typing the client calls. That worked but defeated the purpose of RPC. Tried moving middleware to route level instead of app level — same issue.
Actual Fix
The fix was to define route types explicitly using Hono's AppType and make sure the middleware types are properly chained. Here's the pattern:
// server.ts
import { Hono } from "hono"
const app = new Hono()
.get("/api/users", (c) => {
return c.json({ users: [{ id: 1, name: "Alice" }] })
})
.post("/api/users", async (c) => {
const body = await c.req.json<{ name: string }>()
return c.json({ id: 2, name: body.name }, 201)
})
type AppType = typeof app
export type { AppType }
// client.ts (different package)
import { hc } from "hono/client"
import type { AppType } from "../server"
const client = hc("https://my-api.workers.dev")
async function main() {
// Full type inference — response type is known
const res = await client.api.users.$get()
const data = await res.json()
console.log(data.users[0].name) // TypeScript knows this is string
// POST with typed body
const createRes = await client.api.users.$post({
json: { name: "Bob" }
})
}
Type-Safe Routing with RPC
The RPC client is Hono's killer feature. You share the type of your Hono app between server and client, and the client gets full autocompletion for routes, request bodies, and response types. No OpenAPI spec generation step, no codegen.
This works great in a monorepo where server and client share the same TypeScript project. For separate repos, you export the AppType from a shared package.
Deploying to Multiple Runtimes
The same Hono app can target different runtimes by changing the entry point. Here's how I set up a project that deploys to both Cloudflare Workers and Bun:
// src/app.ts — shared app definition
import { Hono } from "hono"
export const app = new Hono()
app.get("/api/health", (c) => c.json({ status: "ok" }))
// ... all routes
// src/worker.ts — Cloudflare Workers entry
import { app } from "./app"
export default app
// src/bun.ts — Bun entry
import { serve } from "bun"
import { app } from "./app"
serve({
fetch: app.fetch,
port: 3000,
})
# package.json scripts
{
"scripts": {
"dev:worker": "wrangler dev",
"dev:bun": "bun run src/bun.ts",
"deploy:worker": "wrangler deploy",
"deploy:bun": "bun build src/bun.ts --compile --outfile my-api"
}
}
For Deno Deploy, just import directly — no build step needed:
// main.ts for Deno Deploy
import { Hono } from "jsr:@hono/hono"
const app = new Hono()
// Same routes...
Deno.serve(app.fetch)
What I Learned
- Middleware order matters more than in Express. Hono middleware is linear, not a stack. Put CORS before auth, always.
- RPC is worth the setup cost. Once you have type-safe client-server communication, you'll never want to go back to manual API typing.
- Cloudflare Workers has limits. 10ms CPU time on free plan, 50ms on paid. If your API does heavy computation, you need a different runtime.
- D1 is surprisingly good for edge databases. Hono + D1 + KV covers 80% of what I used to use PostgreSQL + Redis for.
- Bun is the best dev experience. Instant startup, fast HMR. I develop locally with Bun and deploy to Workers.
Wrapping Up
Hono replaced Express for all my new projects. The API is familiar enough that the learning curve is measured in hours, not days. Running the same code on Workers, Bun, and Deno means I'm not locked into any single platform. And the RPC client feature eliminates an entire class of type-related bugs between frontend and backend.