Hono — The Ultrafast Web Framework for the Edge
Practical guide to Hono, the lightweight web framework that runs everywhere — Cloudflare Workers, Bun, Deno, Node.js, and Vercel Edge.
Express.js was built in 2010 for a world where servers were long-running processes on dedicated machines. Hono was built for 2024, where your code runs on edge networks across 300 data centers, cold starts matter, and you might deploy to Cloudflare Workers, AWS Lambda, or Deno Deploy depending on the day.
Hono is a small, fast web framework for the edge. It runs on every major JavaScript runtime — Cloudflare Workers, Bun, Deno, Node.js, Fastly, Vercel Edge, AWS Lambda, and more — with the same API. It weighs about 14KB. It's faster than Express by an order of magnitude. And unlike Express, it was designed with TypeScript from the start.
Getting Started
# Create a new project
bun create hono my-api
# Or add to existing project
bun add hono
The simplest Hono app:
// src/index.ts
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("Hello from Hono"));
app.get("/api/users", (c) => {
return c.json([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]);
});
export default app;
That c parameter is the Context object — Hono's equivalent of (req, res) in Express, but designed for modern runtimes. It uses Web Standard APIs (Request, Response) instead of Node-specific ones.
Routing
Hono's router is one of the fastest in the JavaScript ecosystem. It uses a RegExpRouter by default that compiles all routes into a single regex for O(1) matching.
const app = new Hono();
// Basic routes
app.get("/users", (c) => c.json({ users: [] }));
app.post("/users", (c) => c.json({ created: true }, 201));
app.put("/users/:id", (c) => {
const id = c.req.param("id");
return c.json({ updated: id });
});
app.delete("/users/:id", (c) => c.body(null, 204));
// Route parameters
app.get("/users/:userId/posts/:postId", (c) => {
const { userId, postId } = c.req.param();
return c.json({ userId, postId });
});
// Wildcard
app.get("/files/*", (c) => {
const path = c.req.path;
return c.text(Serving file: ${path});
});
// Route grouping
const api = new Hono();
api.get("/users", (c) => c.json([]));
api.get("/posts", (c) => c.json([]));
app.route("/api/v1", api);
Middleware
Middleware in Hono follows the same pattern as Express but with async/await instead of callbacks.
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { bearerAuth } from "hono/bearer-auth";
import { compress } from "hono/compress";
import { etag } from "hono/etag";
import { secureHeaders } from "hono/secure-headers";
const app = new Hono();
// Built-in middleware
app.use("*", logger());
app.use("*", cors({ origin: "https://myapp.com" }));
app.use("*", secureHeaders());
app.use("*", compress());
app.use("*", etag());
// Auth middleware for specific routes
app.use("/api/admin/*", bearerAuth({ token: "my-secret-token" }));
// Custom middleware
app.use("*", async (c, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
c.header("X-Response-Time", ${ms}ms);
});
// Rate limiting (custom)
const rateLimit = new Map<string, { count: number; resetAt: number }>();
app.use("/api/*", async (c, next) => {
const ip = c.req.header("cf-connecting-ip") || "unknown";
const now = Date.now();
const entry = rateLimit.get(ip);
if (entry && entry.resetAt > now && entry.count >= 100) {
return c.json({ error: "Rate limit exceeded" }, 429);
}
if (!entry || entry.resetAt <= now) {
rateLimit.set(ip, { count: 1, resetAt: now + 60_000 });
} else {
entry.count++;
}
await next();
});
Type-Safe Request Handling
Hono can validate request bodies, query parameters, and headers with Zod integration — and the types flow through to your handler.
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono();
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(["admin", "user"]).default("user"),
});
const querySchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
});
// Validated POST body — type is inferred
app.post(
"/api/users",
zValidator("json", createUserSchema),
async (c) => {
const data = c.req.valid("json");
// data is typed as { name: string; email: string; role: "admin" | "user" }
return c.json({ user: data }, 201);
}
);
// Validated query parameters
app.get(
"/api/users",
zValidator("query", querySchema),
async (c) => {
const { page, limit, search } = c.req.valid("query");
// page: number, limit: number, search: string | undefined
return c.json({ page, limit, search });
}
);
No any types. No manual parsing. If someone sends invalid data, Hono returns a 400 error automatically before your handler runs.
Running on Different Platforms
The same Hono app runs everywhere with minimal adapter changes:
// src/app.ts — shared app logic
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.json({ runtime: "universal" }));
export default app;
// For Cloudflare Workers — wrangler.toml points to this
export default app;
// For Bun
export default {
port: 3000,
fetch: app.fetch,
};
// For Node.js
import { serve } from "@hono/node-server";
serve({ fetch: app.fetch, port: 3000 });
// For Deno
Deno.serve({ port: 3000 }, app.fetch);
// For AWS Lambda
import { handle } from "hono/aws-lambda";
export const handler = handle(app);
// For Vercel Edge
export const config = { runtime: "edge" };
export default app;
Write once, deploy anywhere. The app logic stays identical.
Hono vs Express vs Fastify
| Feature | Express | Fastify | Hono |
|---|---|---|---|
| Weight | ~200KB | ~350KB | ~14KB |
| TypeScript | Bolted on (@types) | Built-in | Built-in |
| Edge runtime | No (Node only) | No (Node only) | Yes (everywhere) |
| Requests/sec | ~15,000 | ~50,000 | ~130,000+ |
| Middleware pattern | callback chain | hooks + plugins | async/await chain |
| Validator | Manual / express-validator | JSON Schema (Ajv) | Zod, Valibot, built-in |
| Cold start | Slow (lots of deps) | Medium | Fast (tiny bundle) |
| Community | Massive | Large | Growing fast |
- You're deploying to edge runtimes (Cloudflare Workers, Vercel Edge)
- You need multi-runtime support
- Cold start time matters (serverless)
- You want built-in TypeScript without compromise
- Bundle size is a constraint
- You have a large existing Node.js codebase
- You need the massive Express middleware ecosystem
- Your team knows Express inside out and isn't deploying to the edge
Building a Real API
Here's a more complete example — a REST API with authentication, validation, and error handling:
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "hono/logger";
import { HTTPException } from "hono/http-exception";
import { jwt } from "hono/jwt";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
type Env = {
Variables: {
userId: number;
};
};
const app = new Hono<Env>();
// Global middleware
app.use("*", logger());
app.use("/api/*", cors());
// JWT auth for protected routes
app.use("/api/protected/*", jwt({ secret: "your-secret-key" }));
// Error handling
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
console.error(err);
return c.json({ error: "Internal Server Error" }, 500);
});
// 404 handler
app.notFound((c) => {
return c.json({ error: "Not Found", path: c.req.path }, 404);
});
// Public routes
app.post(
"/api/auth/login",
zValidator("json", z.object({
email: z.string().email(),
password: z.string().min(8),
})),
async (c) => {
const { email, password } = c.req.valid("json");
// ... verify credentials, generate JWT
return c.json({ token: "jwt-token-here" });
}
);
// Protected routes
app.get("/api/protected/profile", async (c) => {
const payload = c.get("jwtPayload");
return c.json({ user: payload });
});
// File uploads
app.post("/api/upload", async (c) => {
const body = await c.req.parseBody();
const file = body["file"];
if (file instanceof File) {
const buffer = await file.arrayBuffer();
// Process file...
return c.json({
name: file.name,
size: file.size,
type: file.type,
});
}
return c.json({ error: "No file provided" }, 400);
});
export default app;
RPC Mode — Full-Stack Type Safety
Hono has an RPC feature that gives you tRPC-like type safety between your API and client. Define routes on the server, and the client gets full autocomplete and type checking.
// Server
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
const app = new Hono()
.get("/api/users", async (c) => {
return c.json([{ id: 1, name: "Alice" }]);
})
.post(
"/api/users",
zValidator("json", z.object({ name: z.string() })),
async (c) => {
const { name } = c.req.valid("json");
return c.json({ id: 2, name }, 201);
}
);
export type AppType = typeof app;
// Client
import { hc } from "hono/client";
import type { AppType } from "./server";
const client = hc<AppType>("http://localhost:3000");
// Fully typed — autocomplete shows available routes and parameters
const users = await client.api.users.$get();
const data = await users.json(); // typed as { id: number; name: string }[]
const newUser = await client.api.users.$post({
json: { name: "Bob" }, // TypeScript enforces this matches the Zod schema
});
No code generation. No schema syncing. The types are inferred directly from the route definitions.
Hono hits a sweet spot that few frameworks manage — it's small enough for edge deployments, fast enough for high-throughput APIs, and typed enough for serious TypeScript projects. If you're building APIs in 2026 and not locked into Express, give it a serious look. More hands-on framework comparisons available at CodeUp.