spinny:~/writing $ less hono-framework-guide.md
12For 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`.34[Hono](https://hono.dev) (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.56## Why Hono78Hono does three things better than anyone else:9101. **Performance.** The `RegExpRouter` compiles 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.112. **Portability.** Web Standards means zero Node dependencies. The same `app.fetch` is exported as the default in a Cloudflare Worker, passed to `Bun.serve`, mounted in a Deno server, or adapted with `@hono/node-server`.123. **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.1314```mermaid15graph LR16 Client[Client] -->|Request| App[app.fetch]17 App --> MW1[Middleware 1]18 MW1 --> MW2[Middleware 2]19 MW2 --> Router[RegExpRouter]20 Router --> Handler[Route Handler]21 Handler --> Context[c.json / c.text]22 Context -->|Response| Client23 App -.->|deploy| CF[Cloudflare Workers]24 App -.->|deploy| Bun[Bun]25 App -.->|deploy| Deno[Deno]26 App -.->|deploy| Node[Node.js]27 App -.->|deploy| Vercel[Vercel]28```2930## Getting Started3132The fastest path is the official starter, which scaffolds the project for your chosen runtime.3334```bash35npm create hono@latest my-api36cd my-api37npm install38npm run dev39```4041The 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:4243```typescript44// src/index.ts45import { Hono } from 'hono'4647const app = new Hono()4849app.get('/', (c) => c.text('Hello Hono!'))5051export default app52```5354On 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.5556## Routing5758Routes are declared with HTTP verb methods and support parameters, wildcards, and regex.5960```typescript61import { Hono } from 'hono'6263const app = new Hono()6465app.get('/', (c) => c.text('Home'))66app.get('/posts/:id', (c) => {67 const id = c.req.param('id') // string, typed68 return c.json({ id })69})70app.get('/posts/:id/comments/:commentId', (c) => {71 const { id, commentId } = c.req.param()72 return c.json({ id, commentId })73})74app.get('/files/*', (c) => c.text('Wildcard'))75app.post('/posts', async (c) => {76 const body = await c.req.json()77 return c.json({ created: body }, 201)78})79```8081Parameters 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.8283### Grouping routes8485`app.route()` lets you compose sub-applications as modules, each with its own prefix.8687```typescript88// routes/posts.ts89import { Hono } from 'hono'9091const posts = new Hono()92posts.get('/', (c) => c.json({ posts: [] }))93posts.get('/:id', (c) => c.json({ id: c.req.param('id') }))94export default posts9596// src/index.ts97import { Hono } from 'hono'98import posts from './routes/posts'99100const app = new Hono()101app.route('/posts', posts)102```103104Nested routes inherit the base path and the type of the root app, so the RPC client sees the entire structure.105106## The Context object107108Every 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.109110```typescript111app.post('/echo', async (c) => {112 // Read113 const userAgent = c.req.header('User-Agent')114 const page = c.req.query('page')115 const body = await c.req.json()116 const env = c.env // bindings (KV, D1, secrets on Cloudflare)117118 // Variables shared between middleware and handler119 c.set('requestId', crypto.randomUUID())120 const id = c.get('requestId')121122 // Response123 c.header('X-Request-Id', id)124 c.status(200)125 return c.json({ userAgent, page, body, id })126})127```128129The 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.130131## Middleware: the onion model132133Middleware 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.134135```typescript136import { Hono } from 'hono'137import { logger } from 'hono/logger'138import { cors } from 'hono/cors'139import { secureHeaders } from 'hono/secure-headers'140141const app = new Hono()142143app.use('*', logger())144app.use('*', secureHeaders())145app.use('/api/*', cors({ origin: 'https://spinny.dev' }))146147app.use('*', async (c, next) => {148 const start = performance.now()149 await next()150 c.header('X-Response-Time', `${performance.now() - start}ms`)151})152```153154`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.155156### Built-in middleware157158Hono ships with a rich set of production-ready middleware, importable from `hono/...`:159160| Middleware | Purpose |161|-----------|---------|162| `logger` | Structured logs of method, path, status, duration |163| `cors` | CORS configurable by origin, methods, headers |164| `csrf` | Origin-based CSRF protection |165| `secureHeaders` | Sets CSP, HSTS, X-Frame-Options |166| `bearerAuth` / `basicAuth` | Out-of-the-box Bearer/Basic auth |167| `jwt` | JWT verify/sign with `jose` |168| `etag` | Generates ETag and handles 304 |169| `cache` | Web Cache API caching |170| `compress` | gzip/deflate response |171| `bodyLimit` | Reject bodies above a threshold |172| `timing` | Server-Timing header for profiling |173174### Type-safe custom middleware175176To extend the `Context` with typed variables, use `createMiddleware`:177178```typescript179import { createMiddleware } from 'hono/factory'180181type AuthVars = { userId: string; role: 'user' | 'admin' }182183export const requireAuth = createMiddleware<{ Variables: AuthVars }>(184 async (c, next) => {185 const token = c.req.header('Authorization')?.replace('Bearer ', '')186 if (!token) return c.json({ error: 'Unauthorized' }, 401)187188 const payload = await verifyJwt(token)189 c.set('userId', payload.sub)190 c.set('role', payload.role)191 await next()192 }193)194195// Usage196app.get('/me', requireAuth, (c) => {197 const userId = c.var.userId // string, typed198 return c.json({ userId })199})200```201202Downstream of the middleware, `c.var.userId` is typed without any cast. This propagates through the entire chain.203204## Validation with Zod205206`@hono/zod-validator` plugs Zod into the request cycle. You define a schema, apply it to the route, and get already-validated, typed inputs.207208```typescript209import { Hono } from 'hono'210import { zValidator } from '@hono/zod-validator'211import { z } from 'zod'212213const createPost = z.object({214 title: z.string().min(1).max(200),215 body: z.string().min(1),216 tags: z.array(z.string()).default([]),217})218219app.post(220 '/posts',221 zValidator('json', createPost),222 (c) => {223 const data = c.req.valid('json') // typed by the schema224 return c.json({ ok: true, post: data }, 201)225 }226)227```228229If 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`.230231## RPC: end-to-end type-safe client232233The 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.234235```typescript236// server.ts237import { Hono } from 'hono'238import { zValidator } from '@hono/zod-validator'239import { z } from 'zod'240241const app = new Hono()242 .get('/posts/:id', (c) =>243 c.json({ id: c.req.param('id'), title: 'Hello' })244 )245 .post(246 '/posts',247 zValidator('json', z.object({ title: z.string(), body: z.string() })),248 (c) => c.json({ ok: true }, 201)249 )250251export type AppType = typeof app252export default app253```254255```typescript256// client.ts257import { hc } from 'hono/client'258import type { AppType } from './server'259260const client = hc<AppType>('https://api.spinny.dev')261262const res = await client.posts[':id'].$get({ param: { id: '42' } })263if (res.ok) {264 const data = await res.json() // { id: string, title: string }265 console.log(data.title)266}267268const created = await client.posts.$post({269 json: { title: 'Hello', body: 'Hono is a flame' }, // validated270})271```272273Rename 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.274275### Discriminating status codes276277If you return different statuses, the client discriminates them automatically.278279```typescript280.get('/posts/:id', (c) => {281 const post = findPost(c.req.param('id'))282 if (!post) return c.json({ error: 'not found' }, 404)283 return c.json({ post }, 200)284})285```286287```typescript288const res = await client.posts[':id'].$get({ param: { id } })289if (res.status === 404) {290 const { error } = await res.json() // { error: string }291}292if (res.status === 200) {293 const { post } = await res.json() // { post: Post }294}295```296297## Routers and performance298299Hono offers five routers with different tradeoffs. The default is `SmartRouter`, which on startup measures which router serves your routes best and locks in.300301| Router | Strengths | When to use |302|--------|-----------|-------------|303| `RegExpRouter` | Top speed, compiled regex | Default for most APIs |304| `TrieRouter` | Supports every pattern | Complex patterns RegExp can't handle |305| `SmartRouter` | Picks the best automatically | Recommended default |306| `LinearRouter` | Ultra-fast registration | One-shot workers, cold-start critical |307| `PatternRouter` | Smallest bundle (<15KB) | Extreme size constraints |308309For 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.310311```typescript312import { Hono } from 'hono'313import { LinearRouter } from 'hono/router/linear-router'314315const app = new Hono({ router: new LinearRouter() })316```317318## Multi-runtime deployment319320The same `export default app` changes target by swapping only the entry file.321322### Cloudflare Workers323324```typescript325// src/index.ts326import { Hono } from 'hono'327328type Bindings = { MY_KV: KVNamespace; DB: D1Database }329const app = new Hono<{ Bindings: Bindings }>()330331app.get('/cache/:key', async (c) => {332 const value = await c.env.MY_KV.get(c.req.param('key'))333 return c.json({ value })334})335336export default app337```338339Deploy: `npx wrangler deploy`. KV/D1/R2/Queues bindings are native.340341### Bun342343```typescript344import { Hono } from 'hono'345const app = new Hono()346app.get('/', (c) => c.text('Bun + Hono'))347348Bun.serve({ fetch: app.fetch, port: 3000 })349```350351### Node.js352353```typescript354import { serve } from '@hono/node-server'355import { Hono } from 'hono'356357const app = new Hono()358app.get('/', (c) => c.text('Node + Hono'))359360serve({ fetch: app.fetch, port: 3000 })361```362363### Deno364365```typescript366import { Hono } from 'jsr:@hono/hono'367const app = new Hono()368app.get('/', (c) => c.text('Deno + Hono'))369Deno.serve(app.fetch)370```371372### Vercel373374```typescript375// api/[[...route]].ts376import { Hono } from 'hono'377import { handle } from 'hono/vercel'378379const app = new Hono().basePath('/api')380app.get('/hello', (c) => c.json({ msg: 'Hello from Vercel' }))381382export const GET = handle(app)383export const POST = handle(app)384```385386Same business code, same routing, same middleware. Only the adapter changes.387388## Real-world example: REST API with auth and DB389390Putting it together. A blog API on Cloudflare Workers + D1, with JWT auth, validation, and RPC.391392```typescript393import { Hono } from 'hono'394import { jwt } from 'hono/jwt'395import { logger } from 'hono/logger'396import { cors } from 'hono/cors'397import { zValidator } from '@hono/zod-validator'398import { z } from 'zod'399400type Bindings = { DB: D1Database; JWT_SECRET: string }401type Variables = { jwtPayload: { sub: string } }402403const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()404405app.use('*', logger())406app.use('/api/*', cors({ origin: 'https://spinny.dev', credentials: true }))407408const auth = (c: any, next: any) =>409 jwt({ secret: c.env.JWT_SECRET })(c, next)410411const api = app.basePath('/api')412413api.get('/posts', async (c) => {414 const { results } = await c.env.DB415 .prepare('SELECT id, title, created_at FROM posts ORDER BY created_at DESC LIMIT 50')416 .all()417 return c.json({ posts: results })418})419420api.post(421 '/posts',422 auth,423 zValidator('json', z.object({424 title: z.string().min(1).max(200),425 body: z.string().min(1),426 })),427 async (c) => {428 const { title, body } = c.req.valid('json')429 const userId = c.var.jwtPayload.sub430 const result = await c.env.DB431 .prepare('INSERT INTO posts (title, body, author_id) VALUES (?, ?, ?) RETURNING id')432 .bind(title, body, userId)433 .first<{ id: number }>()434 return c.json({ id: result?.id }, 201)435 }436)437438api.onError((err, c) => {439 console.error(err)440 return c.json({ error: 'Internal error' }, 500)441})442443export type AppType = typeof api444export default app445```446447A React client consumes it with full type safety:448449```typescript450import { hc } from 'hono/client'451import type { AppType } from '../api/src/index'452453const api = hc<AppType>(import.meta.env.VITE_API_URL)454455const res = await api.posts.$post({456 json: { title: 'Hono in 2026', body: '...' },457}, {458 headers: { Authorization: `Bearer ${token}` },459})460```461462## Testing463464`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.465466```typescript467import { describe, it, expect } from 'vitest'468import app from '../src/index'469470describe('GET /api/posts', () => {471 it('returns the list of posts', async () => {472 const res = await app.request('/api/posts')473 expect(res.status).toBe(200)474 const body = await res.json()475 expect(body.posts).toBeInstanceOf(Array)476 })477})478```479480On Cloudflare Workers, `@cloudflare/vitest-pool-workers` runs the same tests inside a real Worker with mock bindings - maximum realism, zero deploy.481482## Best practices483484### 1. Chain route definitions485486```typescript487const app = new Hono()488 .get('/posts', handler1)489 .post('/posts', handler2)490 .get('/posts/:id', handler3)491```492493Chaining 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.494495### 2. Export the type, not the implementation496497The client must import `AppType`, not the app. `import type` ensures the frontend build doesn't include backend code.498499### 3. One router per domain500501A 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.502503### 4. Validate at the edge, always504505Every 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.506507### 5. Lean on bindings, not global clients508509On 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.510511### 6. Measure before optimizing the router512513The default `SmartRouter` is fine for 95% of cases. Switch routers only after profiling and seeing a real bottleneck.514515## Conclusion516517Hono 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.518519You'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.520521> **Getting Started Checklist:**522>523> - [x] `npm create hono@latest` and pick your runtime template524> - [x] Define routes with chaining (`.get(...).post(...)`)525> - [x] Add `logger`, `cors`, `secureHeaders` as global middleware526> - [x] Validate every input with `@hono/zod-validator`527> - [x] Export `AppType` and consume the API with the type-safe `hc` client528> - [x] Write tests with `app.request()` - no HTTP server needed529> - [x] Deploy with `wrangler deploy` (CF), `vercel deploy`, or your runtime's bundler530
:Hono: The Ultrafast Web Framework Built on Web Standardslines 1-530 (END) — press q to close