Hono logoHonov4INTERMEDIATE

Hono

Hono cheat sheet covering routing, middleware, context API, validation, RPC client, and deployment to Cloudflare Workers and Bun.

5 min read
honoweb-frameworkcloudflare-workersbundenonodejstypescriptapiedgeserverless
Loading your progress

Getting Started

Initialize a Hono project with create-hono CLI

bash
# Create new Hono project
npm create hono@latest my-app
# or
bun create hono my-app
# or
yarn create hono my-app

# Select your runtime:
# - cloudflare-workers
# - cloudflare-pages
# - deno
# - bun
# - nodejs
# - vercel
# - aws-lambda
💡 Hono works on any JavaScript runtime with Web Standards support
⚡ Zero dependencies - ultrafast and lightweight (~14KB)
📌 TypeScript types are included - no @types package needed
🔥 Use Bun for fastest local development experience

Create your first Hono application

typescript
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => {
  return c.text('Hello Hono!')
})

export default app
💡 The context object "c" provides all request/response utilities
⚡ c.text(), c.json(), c.html() - common response helpers
📌 Export default for Cloudflare Workers and Bun
🎯 Hono uses Web Standard Request/Response APIs

Routing

Define routes for different HTTP methods

typescript
// HTTP Methods
app.get('/users', (c) => c.text('GET /users'))
app.post('/users', (c) => c.text('POST /users'))
app.put('/users/:id', (c) => c.text('PUT /users/:id'))
app.delete('/users/:id', (c) => c.text('DELETE /users/:id'))
app.patch('/users/:id', (c) => c.text('PATCH'))

// Any HTTP method
app.all('/hello', (c) => c.text('Any Method'))

// Custom method
app.on('PURGE', '/cache', (c) => c.text('PURGE'))

// Multiple methods
app.on(['PUT', 'DELETE'], '/resource', (c) => {
  return c.text('PUT or DELETE')
})
💡 Use app.all() to match any HTTP method
⚡ Wildcards: * matches one segment, trailing * matches all
📌 app.on() for custom methods or multiple methods
🎯 Use app.route() for modular route grouping

Path Parameters

Extract dynamic values from URLs

typescript
// Single parameter
app.get('/users/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ id })
})

// Multiple parameters
app.get('/posts/:postId/comments/:commentId', (c) => {
  const { postId, commentId } = c.req.param()
  return c.json({ postId, commentId })
})

// Optional parameter (using regex)
app.get('/books/:id{[0-9]+}?', (c) => {
  const id = c.req.param('id') // undefined if not provided
  return c.json({ id })
})
💡 c.req.param("name") gets single param, c.req.param() gets all
⚡ Use regex patterns for validation: :id{[0-9]+}
📌 Wildcard * captures everything after the path segment
🎯 Optional params: :param? matches with or without

Organize routes with grouping and base paths

typescript
// Basepath for entire app
const app = new Hono().basePath('/api/v1')

app.get('/users', (c) => c.json([])) // /api/v1/users
app.get('/posts', (c) => c.json([])) // /api/v1/posts

// Route grouping with sub-apps
const users = new Hono()
users.get('/', (c) => c.json([]))
users.get('/:id', (c) => c.json({}))
users.post('/', (c) => c.json({}, 201))

const posts = new Hono()
posts.get('/', (c) => c.json([]))

const app = new Hono()
app.route('/users', users)
app.route('/posts', posts)
💡 Use .basePath() to prefix all routes in an app
⚡ app.route(path, subApp) mounts sub-apps at a path
📌 Chain methods for proper RPC type inference
🎯 Export typeof app for type-safe RPC client

Context & Request

Request Data

Access query params, headers, body, and more

typescript
// Query parameters
app.get('/search', (c) => {
  const query = c.req.query('q')        // single
  const all = c.req.queries('tags')     // array
  return c.json({ query, all })
})

// Headers
app.get('/auth', (c) => {
  const auth = c.req.header('Authorization')
  const userAgent = c.req.header('User-Agent')
  return c.json({ auth, userAgent })
})

// Body parsing
app.post('/users', async (c) => {
  const body = await c.req.json()       // JSON
  const form = await c.req.formData()   // FormData
  const text = await c.req.text()       // Raw text
  return c.json(body)
})
💡 c.req.query() for single, c.req.queries() for arrays
⚡ Body methods are async: await c.req.json()
📌 Headers are case-insensitive
🎯 Use getCookie from "hono/cookie" for cookies

Different ways to send responses

typescript
// Text response
app.get('/text', (c) => c.text('Hello!'))

// JSON response
app.get('/json', (c) => c.json({ msg: 'Hello!' }))

// HTML response
app.get('/html', (c) => c.html('<h1>Hello!</h1>'))

// With status code
app.post('/create', (c) => c.json({ id: 1 }, 201))

// Redirect
app.get('/old', (c) => c.redirect('/new'))
app.get('/external', (c) => c.redirect('https://hono.dev', 301))

// Set headers
app.get('/custom', (c) => {
  c.header('X-Custom', 'value')
  c.status(201)
  return c.json({ ok: true })
})
💡 c.json(), c.text(), c.html() are convenience methods
⚡ Pass status code as second argument: c.json(data, 201)
📌 Use c.header() to set response headers
🎯 Import cookie helpers from "hono/cookie"

Store and retrieve values in request context

typescript
// Set context variable in middleware
app.use('*', async (c, next) => {
  c.set('userId', '12345')
  c.set('startTime', Date.now())
  await next()
})

// Get context variable in handler
app.get('/profile', (c) => {
  const userId = c.get('userId')
  return c.json({ userId })
})

// Type-safe context with generics
type Variables = {
  userId: string
  user: { name: string; email: string }
}

const app = new Hono<{ Variables: Variables }>()
💡 c.set(key, value) stores, c.get(key) retrieves
⚡ Use generics for type-safe context variables
📌 Bindings are for environment variables (Cloudflare)
🎯 Variables persist throughout the request lifecycle

Middleware

Define and use custom middleware

typescript
// Basic middleware
app.use('*', async (c, next) => {
  console.log('Before handler')
  await next()
  console.log('After handler')
})

// Path-specific middleware
app.use('/api/*', async (c, next) => {
  const start = Date.now()
  await next()
  const ms = Date.now() - start
  c.header('X-Response-Time', \`\${ms}ms\`)
})

// Using createMiddleware for type safety
import { createMiddleware } from 'hono/factory'

const authMiddleware = createMiddleware(async (c, next) => {
  const token = c.req.header('Authorization')
  if (!token) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  await next()
})
💡 Always call await next() to continue the chain
⚡ Middleware runs before handler, after next() returns
📌 Use createMiddleware from "hono/factory" for types
🎯 Return a response to short-circuit the chain

Common middleware included with Hono

typescript
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { prettyJSON } from 'hono/pretty-json'
import { compress } from 'hono/compress'
import { etag } from 'hono/etag'
import { secureHeaders } from 'hono/secure-headers'

const app = new Hono()

// Logging
app.use('*', logger())

// CORS
app.use('/api/*', cors({
  origin: 'https://example.com',
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  credentials: true,
}))

// Pretty JSON (add ?pretty to URLs)
app.use('*', prettyJSON())

// Response compression
app.use('*', compress())
💡 logger() adds request/response logging
⚡ cors() handles CORS preflight automatically
📌 secureHeaders() adds security headers (CSP, etc.)
🎯 compress() gzips responses for smaller payloads

Basic Auth, Bearer Auth, and JWT middleware

typescript
import { basicAuth } from 'hono/basic-auth'
import { bearerAuth } from 'hono/bearer-auth'
import { jwt } from 'hono/jwt'

// Basic Auth
app.use('/admin/*', basicAuth({
  username: 'admin',
  password: 'secret123',
}))

// Bearer Token Auth
app.use('/api/*', bearerAuth({
  token: 'my-secret-token',
}))

// JWT Auth
app.use('/auth/*', jwt({
  secret: 'my-jwt-secret',
}))

app.get('/auth/profile', (c) => {
  const payload = c.get('jwtPayload')
  return c.json(payload)
})
💡 basicAuth sends WWW-Authenticate header on failure
⚡ bearerAuth expects "Authorization: Bearer <token>" header
📌 JWT payload accessible via c.get("jwtPayload")
🎯 Use sign() from "hono/jwt" to create tokens

Validation

Zod Validation

Type-safe validation with Zod schemas

typescript
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

// JSON body validation
const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age: z.number().min(18).optional(),
})

app.post('/users',
  zValidator('json', createUserSchema),
  (c) => {
    const { name, email, age } = c.req.valid('json')
    return c.json({ name, email, age }, 201)
  }
)

// Query params validation
const searchSchema = z.object({
  q: z.string(),
  page: z.coerce.number().positive().default(1),
  limit: z.coerce.number().max(100).default(10),
})

app.get('/search',
  zValidator('query', searchSchema),
  (c) => {
    const { q, page, limit } = c.req.valid('query')
    return c.json({ q, page, limit })
  }
)
💡 Install: npm install @hono/zod-validator zod
⚡ Targets: json, query, param, header, form, cookie
📌 Use z.coerce for automatic type conversion
🎯 c.req.valid("target") returns typed validated data

Use Hono's built-in validator without external deps

typescript
import { validator } from 'hono/validator'

// Custom validation function
app.post('/posts',
  validator('json', (value, c) => {
    const { title, body } = value
    if (!title || typeof title !== 'string') {
      return c.json({ error: 'Title is required' }, 400)
    }
    if (!body || body.length < 10) {
      return c.json({ error: 'Body too short' }, 400)
    }
    return { title, body } // Return validated data
  }),
  (c) => {
    const { title, body } = c.req.valid('json')
    return c.json({ title, body }, 201)
  }
)
💡 Built-in validator works without external libraries
⚡ Return c.json() or c.text() to short-circuit with error
📌 Return validated data to pass to handler
🎯 Supports async validation for database checks

RPC Client

Create a type-safe API client from your Hono app

typescript
// server.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()
  .get('/users', (c) => c.json({ users: [] }))
  .get('/users/:id', (c) => {
    const id = c.req.param('id')
    return c.json({ id, name: 'John' })
  })
  .post('/users',
    zValidator('json', z.object({
      name: z.string(),
      email: z.string().email(),
    })),
    (c) => {
      const data = c.req.valid('json')
      return c.json({ id: '123', ...data }, 201)
    }
  )

export type AppType = typeof app
export default app
💡 Chain methods (.get().post()) for proper type inference
⚡ Export "typeof app" as AppType for client usage
📌 Validation schemas are reflected in client types
🎯 Works with route groups via .route()

Client Usage

Use the type-safe RPC client

typescript
// client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'

const client = hc<AppType>('http://localhost:8787')

// GET request
const res = await client.users.$get()
const data = await res.json() // Typed!

// GET with path params
const user = await client.users[':id'].$get({
  param: { id: '123' }
})

// POST with JSON body
const created = await client.users.$post({
  json: { name: 'John', email: 'john@example.com' }
})

// Check status
if (created.status === 201) {
  const newUser = await created.json()
}
💡 hc<AppType>(baseUrl) creates typed client
⚡ Path params: client.users[":id"].$get({ param: {} })
📌 JSON body: $post({ json: {} }), query: $get({ query: {} })
🎯 InferRequestType/InferResponseType for type extraction

Streaming & SSE

Stream real-time updates to clients

typescript
import { streamSSE } from 'hono/streaming'

let eventId = 0

app.get('/sse', async (c) => {
  return streamSSE(c, async (stream) => {
    while (true) {
      await stream.writeSSE({
        data: JSON.stringify({ time: new Date().toISOString() }),
        event: 'time-update',
        id: String(eventId++),
      })
      await stream.sleep(1000)
    }
  })
})

// Client-side
const eventSource = new EventSource('/sse')
eventSource.addEventListener('time-update', (e) => {
  console.log(JSON.parse(e.data))
})
💡 streamSSE automatically sets correct SSE headers
⚡ Use stream.sleep(ms) for intervals, not setTimeout
📌 stream.onAbort() handles client disconnection
🎯 Client uses EventSource API for SSE

WebSockets

Real-time bidirectional communication

typescript
import { Hono } from 'hono'
import { upgradeWebSocket } from 'hono/cloudflare-workers'
// or: 'hono/bun', 'hono/deno'

const app = new Hono()

app.get('/ws', upgradeWebSocket((c) => {
  return {
    onOpen(event, ws) {
      console.log('Client connected')
      ws.send('Welcome!')
    },
    onMessage(event, ws) {
      console.log('Received:', event.data)
      ws.send(\`Echo: \${event.data}\`)
    },
    onClose(event, ws) {
      console.log('Client disconnected')
    },
    onError(event, ws) {
      console.log('Error:', event)
    },
  }
}))
💡 Import upgradeWebSocket from runtime-specific path
⚡ Bun requires exporting websocket with app.fetch
📌 onOpen not supported on Cloudflare Workers
🎯 Use RPC mode for type-safe WebSocket client

Error Handling

Handle errors gracefully in your app

typescript
import { HTTPException } from 'hono/http-exception'

// Throw HTTP errors
app.get('/users/:id', async (c) => {
  const user = await db.findUser(c.req.param('id'))
  if (!user) {
    throw new HTTPException(404, { message: 'User not found' })
  }
  return c.json(user)
})

// Global error handler
app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse()
  }
  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)
})
💡 HTTPException creates standard HTTP error responses
⚡ app.onError() catches all unhandled errors
📌 Route-level onError takes precedence over global
🎯 app.notFound() handles 404s

Deployment

Deploy to Cloudflare Workers edge network

typescript
// wrangler.toml
name = "my-hono-app"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[vars]
API_KEY = "dev-key"

// src/index.ts
import { Hono } from 'hono'

type Bindings = {
  API_KEY: string
  MY_KV: KVNamespace
  MY_DB: D1Database
}

const app = new Hono<{ Bindings: Bindings }>()

app.get('/', (c) => {
  const apiKey = c.env.API_KEY
  return c.json({ key: apiKey })
})

export default app

# Deploy
npx wrangler deploy
💡 Use Bindings type for type-safe environment access
⚡ Access bindings via c.env in handlers
📌 .dev.vars for local secrets (add to .gitignore)
🎯 npx wrangler deploy for production

Deploy to other JavaScript runtimes

typescript
// ========== BUN ==========
// src/index.ts
import { Hono } from 'hono'

const app = new Hono()
app.get('/', (c) => c.text('Hello Bun!'))

export default app
// or: export default { port: 3000, fetch: app.fetch }

# Run
bun run src/index.ts

// ========== DENO ==========
// main.ts
import { Hono } from 'npm:hono'

const app = new Hono()
app.get('/', (c) => c.text('Hello Deno!'))

Deno.serve(app.fetch)

# Run
deno run --allow-net main.ts

// ========== NODE.JS ==========
import { Hono } from 'hono'
import { serve } from '@hono/node-server'

const app = new Hono()
app.get('/', (c) => c.text('Hello Node!'))

serve({ fetch: app.fetch, port: 3000 })
💡 Bun: export default app or { port, fetch }
⚡ Deno: Deno.serve(app.fetch)
📌 Node: npm install @hono/node-server
🎯 Vercel/Lambda: use handle() adapter

Testing

Test your Hono app without starting a server

typescript
import { describe, it, expect } from 'vitest'
import app from './app'

describe('API Tests', () => {
  it('GET / returns hello', async () => {
    const res = await app.request('/')
    expect(res.status).toBe(200)
    expect(await res.text()).toBe('Hello Hono!')
  })

  it('POST /users creates user', async () => {
    const res = await app.request('/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'John' }),
    })
    expect(res.status).toBe(201)
    const data = await res.json()
    expect(data.name).toBe('John')
  })
})
💡 app.request() returns a real Response object
⚡ No server needed - tests run in memory
📌 Pass third argument for Cloudflare bindings/env
🎯 Works with Vitest, Jest, or any test runner