For years, picking a JavaScript web framework meant accepting a tradeoff: Express was universal but slow and Node-bound, Fastify was fast but Node-only, Next.js was full-featured but heavy. When edge runtimes arrived - Cloudflare Workers, Deno Deploy, Bun, Vercel Edge - those frameworks showed their limits: incompatible dependencies, huge bundles, APIs tied to Node's req/res.
Hono (Japanese for "flame" 🔥) is the modern answer. It's a sub-14KB framework built entirely on Web Standards (Request, Response, fetch) that runs anywhere a JavaScript runtime exists. The same code deploys to Cloudflare Workers, Bun, Deno, Node.js, Vercel, Netlify, and AWS Lambda - without changes.
Why Hono
Hono does three things better than anyone else:
- Performance. The
RegExpRoutercompiles every route pattern into a single regex, avoiding the linear loops of traditional routers. Benchmarks show over 400,000 ops/s, putting Hono among the fastest routers in the JavaScript ecosystem. - Portability. Web Standards means zero Node dependencies. The same
app.fetchis exported as the default in a Cloudflare Worker, passed toBun.serve, mounted in a Deno server, or adapted with@hono/node-server. - TypeScript-first DX. Path parameters typed as literals, end-to-end type-safe RPC client, validators that infer input and output types. Autocomplete is almost telepathic.
Getting Started
The fastest path is the official starter, which scaffolds the project for your chosen runtime.
npm create hono@latest my-api cd my-api npm install npm run dev
The starter prompts for a template: cloudflare-workers, bun, deno, nodejs, vercel, aws-lambda, nextjs, and more. It picks the right config and deploy scripts. To try it on the fly, you can also start from a single file:
// src/index.ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hello Hono!')) export default app
On Cloudflare Workers this is enough. On Bun: Bun.serve({ fetch: app.fetch, port: 3000 }). On Node: serve({ fetch: app.fetch }) from @hono/node-server. The surface is identical.
Routing
Routes are declared with HTTP verb methods and support parameters, wildcards, and regex.
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Home')) app.get('/posts/:id', (c) => { const id = c.req.param('id') // string, typed return c.json({ id }) }) app.get('/posts/:id/comments/:commentId', (c) => { const { id, commentId } = c.req.param() return c.json({ id, commentId }) }) app.get('/files/*', (c) => c.text('Wildcard')) app.post('/posts', async (c) => { const body = await c.req.json() return c.json({ created: body }, 201) })
Parameters are inferred as literal types: TypeScript knows c.req.param('id') returns a string only if you declared :id in the pattern. Misspelling it is a compile-time error.
Grouping routes
app.route() lets you compose sub-applications as modules, each with its own prefix.
// routes/posts.ts import { Hono } from 'hono' const posts = new Hono() posts.get('/', (c) => c.json({ posts: [] })) posts.get('/:id', (c) => c.json({ id: c.req.param('id') })) export default posts // src/index.ts import { Hono } from 'hono' import posts from './routes/posts' const app = new Hono() app.route('/posts', posts)
Nested routes inherit the base path and the type of the root app, so the RPC client sees the entire structure.
The Context object
Every handler receives a c - the Context for the current request. It's the only API you need to learn to read inputs and produce outputs.
app.post('/echo', async (c) => { // Read const userAgent = c.req.header('User-Agent') const page = c.req.query('page') const body = await c.req.json() const env = c.env // bindings (KV, D1, secrets on Cloudflare) // Variables shared between middleware and handler c.set('requestId', crypto.randomUUID()) const id = c.get('requestId') // Response c.header('X-Request-Id', id) c.status(200) return c.json({ userAgent, page, body, id }) })
The main response methods are c.text, c.json, c.html, c.body (raw), and c.redirect. All accept a status code as a second argument.
Middleware: the onion model
Middleware are (c, next) => ... functions that can run code before and after the handler. Composed, they form the classic onion model: the first registered middleware is the first to start and the last to finish.
import { Hono } from 'hono' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { secureHeaders } from 'hono/secure-headers' const app = new Hono() app.use('*', logger()) app.use('*', secureHeaders()) app.use('/api/*', cors({ origin: 'https://spinny.dev' })) app.use('*', async (c, next) => { const start = performance.now() await next() c.header('X-Response-Time', `${performance.now() - start}ms`) })
await next() yields control to the next middleware. Anything after it runs on the response phase, after the handler has produced a result. Returning a Response before next() short-circuits the chain.
Built-in middleware
Hono ships with a rich set of production-ready middleware, importable from hono/...:
| Middleware | Purpose |
|---|---|
logger | Structured logs of method, path, status, duration |
cors | CORS configurable by origin, methods, headers |
csrf | Origin-based CSRF protection |
secureHeaders | Sets CSP, HSTS, X-Frame-Options |
bearerAuth / basicAuth | Out-of-the-box Bearer/Basic auth |
jwt | JWT verify/sign with jose |
etag | Generates ETag and handles 304 |
cache | Web Cache API caching |
compress | gzip/deflate response |
bodyLimit | Reject bodies above a threshold |
timing | Server-Timing header for profiling |
Type-safe custom middleware
To extend the Context with typed variables, use createMiddleware:
import { createMiddleware } from 'hono/factory' type AuthVars = { userId: string; role: 'user' | 'admin' } export const requireAuth = createMiddleware<{ Variables: AuthVars }>( async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', '') if (!token) return c.json({ error: 'Unauthorized' }, 401) const payload = await verifyJwt(token) c.set('userId', payload.sub) c.set('role', payload.role) await next() } ) // Usage app.get('/me', requireAuth, (c) => { const userId = c.var.userId // string, typed return c.json({ userId }) })
Downstream of the middleware, c.var.userId is typed without any cast. This propagates through the entire chain.
Validation with Zod
@hono/zod-validator plugs Zod into the request cycle. You define a schema, apply it to the route, and get already-validated, typed inputs.
import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const createPost = z.object({ title: z.string().min(1).max(200), body: z.string().min(1), tags: z.array(z.string()).default([]), }) app.post( '/posts', zValidator('json', createPost), (c) => { const data = c.req.valid('json') // typed by the schema return c.json({ ok: true, post: data }, 201) } )
If the body fails validation, Hono replies 400 with the Zod error before even calling the handler. You can also validate query, param, header, cookie, and form.
RPC: end-to-end type-safe client
The feature that truly sets Hono apart is RPC mode. Export your app's type from the server, the hc client imports it, and you get full autocomplete - including paths, query, body, headers, and response - with no codegen or OpenAPI.
// server.ts import { Hono } from 'hono' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' const app = new Hono() .get('/posts/:id', (c) => c.json({ id: c.req.param('id'), title: 'Hello' }) ) .post( '/posts', zValidator('json', z.object({ title: z.string(), body: z.string() })), (c) => c.json({ ok: true }, 201) ) export type AppType = typeof app export default app
// client.ts import { hc } from 'hono/client' import type { AppType } from './server' const client = hc<AppType>('https://api.spinny.dev') const res = await client.posts[':id'].$get({ param: { id: '42' } }) if (res.ok) { const data = await res.json() // { id: string, title: string } console.log(data.title) } const created = await client.posts.$post({ json: { title: 'Hello', body: 'Hono is a flame' }, // validated })
Rename a route on the server and the client TypeScript breaks immediately in CI. Same advantage as tRPC, but over standard HTTP, with no specific middleware, and a tiny bundle.
Discriminating status codes
If you return different statuses, the client discriminates them automatically.
.get('/posts/:id', (c) => { const post = findPost(c.req.param('id')) if (!post) return c.json({ error: 'not found' }, 404) return c.json({ post }, 200) })
const res = await client.posts[':id'].$get({ param: { id } }) if (res.status === 404) { const { error } = await res.json() // { error: string } } if (res.status === 200) { const { post } = await res.json() // { post: Post } }
Routers and performance
Hono offers five routers with different tradeoffs. The default is SmartRouter, which on startup measures which router serves your routes best and locks in.
| Router | Strengths | When to use |
|---|---|---|
RegExpRouter | Top speed, compiled regex | Default for most APIs |
TrieRouter | Supports every pattern | Complex patterns RegExp can't handle |
SmartRouter | Picks the best automatically | Recommended default |
LinearRouter | Ultra-fast registration | One-shot workers, cold-start critical |
PatternRouter | Smallest bundle (<15KB) | Extreme size constraints |
For stateless workers with frequent cold starts, LinearRouter skips the initial compilation cost and is 33x faster than find-my-way when measuring registration plus matching.
import { Hono } from 'hono' import { LinearRouter } from 'hono/router/linear-router' const app = new Hono({ router: new LinearRouter() })
Multi-runtime deployment
The same export default app changes target by swapping only the entry file.
Cloudflare Workers
// src/index.ts import { Hono } from 'hono' type Bindings = { MY_KV: KVNamespace; DB: D1Database } const app = new Hono<{ Bindings: Bindings }>() app.get('/cache/:key', async (c) => { const value = await c.env.MY_KV.get(c.req.param('key')) return c.json({ value }) }) export default app
Deploy: npx wrangler deploy. KV/D1/R2/Queues bindings are native.
Bun
import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Bun + Hono')) Bun.serve({ fetch: app.fetch, port: 3000 })
Node.js
import { serve } from '@hono/node-server' import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Node + Hono')) serve({ fetch: app.fetch, port: 3000 })
Deno
import { Hono } from 'jsr:@hono/hono' const app = new Hono() app.get('/', (c) => c.text('Deno + Hono')) Deno.serve(app.fetch)
Vercel
// api/[[...route]].ts import { Hono } from 'hono' import { handle } from 'hono/vercel' const app = new Hono().basePath('/api') app.get('/hello', (c) => c.json({ msg: 'Hello from Vercel' })) export const GET = handle(app) export const POST = handle(app)
Same business code, same routing, same middleware. Only the adapter changes.
Real-world example: REST API with auth and DB
Putting it together. A blog API on Cloudflare Workers + D1, with JWT auth, validation, and RPC.
import { Hono } from 'hono' import { jwt } from 'hono/jwt' import { logger } from 'hono/logger' import { cors } from 'hono/cors' import { zValidator } from '@hono/zod-validator' import { z } from 'zod' type Bindings = { DB: D1Database; JWT_SECRET: string } type Variables = { jwtPayload: { sub: string } } const app = new Hono<{ Bindings: Bindings; Variables: Variables }>() app.use('*', logger()) app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true })) const auth = (c: any, next: any) => jwt({ secret: c.env.JWT_SECRET })(c, next) const api = app.basePath('/api') api.get('/posts', async (c) => { const { results } = await c.env.DB .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50') .all() return c.json({ posts: results }) }) api.post( '/posts', auth, zValidator('json', z.object({ title: z.string().min(1).max(200), body: z.string().min(1), })), async (c) => { const { title, body } = c.req.valid('json') const userId = c.var.jwtPayload.sub const result = await c.env.DB .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id') .bind(title, body, userId) .first<{ id: number }>() return c.json({ id: result?.id }, 201) } ) api.onError((err, c) => { console.error(err) return c.json({ error: 'Internal error' }, 500) }) export type AppType = typeof api export default app
A React client consumes it with full type safety:
import { hc } from 'hono/client' import type { AppType } from '../api/src/index' const api = hc<AppType>(import.meta.env.VITE_API_URL) const res = await api.posts.$post({ json: { title: 'Hono in 2026', body: '...' }, }, { headers: { Authorization: `Bearer ${token}` }, })
Testing
app.request() lets you test routes without spinning up an HTTP server. It's the same path you'd run in production, executed in-memory.
import { describe, it, expect } from 'vitest' import app from '../src/index' describe('GET /api/posts', () => { it('returns the list of posts', async () => { const res = await app.request('/api/posts') expect(res.status).toBe(200) const body = await res.json() expect(body.posts).toBeInstanceOf(Array) }) })
On Cloudflare Workers, @cloudflare/vitest-pool-workers runs the same tests inside a real Worker with mock bindings - maximum realism, zero deploy.
Best practices
1. Chain route definitions
const app = new Hono() .get('/posts', handler1) .post('/posts', handler2) .get('/posts/:id', handler3)
Chaining keeps app's type up to date with every route, which is essential for the RPC client. Defining them separately as app.get(...) on distinct lines breaks inference.
2. Export the type, not the implementation
The client must import AppType, not the app. import type ensures the frontend build doesn't include backend code.
3. One router per domain
A sub-app for posts, one for users, one for webhooks. Compose them with app.route() and each owns its middleware. The structure scales without mega-files.
4. Validate at the edge, always
Every external input (body, query, header) must go through zValidator. Don't trust data downstream: even a TypeScript cast without runtime validation is a bug waiting to happen.
5. Lean on bindings, not global clients
On Cloudflare, access KV/D1/R2 via c.env. No global singletons, no connections persisting across Workers. The stateless model is a feature, not a limitation.
6. Measure before optimizing the router
The default SmartRouter is fine for 95% of cases. Switch routers only after profiling and seeing a real bottleneck.
Conclusion
Hono has become the de facto standard in 2026 for building edge-ready APIs in TypeScript. The combination of Web Standards, performance, type safety, and portability solves exactly the problems that held back traditional frameworks: runtime lock-in, heavy bundles, fragile type system between client and server.
You'll use it for microservices on Cloudflare Workers, to replace Express in a Node monolith, for a function on Vercel, or for an API on Bun. The same knowledge transfers everywhere - and that's what makes it a solid investment for the next few years.
Getting Started Checklist:
npm create hono@latestand pick your runtime template- Define routes with chaining (
.get(...).post(...))- Add
logger,cors,secureHeadersas global middleware- Validate every input with
@hono/zod-validator- Export
AppTypeand consume the API with the type-safehcclient- Write tests with
app.request()- no HTTP server needed- Deploy with
wrangler deploy(CF),vercel deploy, or your runtime's bundler