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:

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

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.

Related Articles