Introduction to Hono
Hono is a lightweight, ultrafast web framework designed for the modern JavaScript and TypeScript ecosystem. Its name comes from the Japanese word for "flame" or "blaze" (Flame), reflecting its blazing-fast performance. Built with a focus on speed, small bundle size, and multi-runtime compatibility, Hono has quickly become a top choice for developers building APIs, edge applications, and serverless functions across platforms like Cloudflare Workers, Deno, Bun, Node.js, and AWS Lambda.
At its core, Hono provides an intuitive, Express-like API for handling HTTP requests and responses. But unlike Express, which was built for Node.js in an era before serverless and edge computing, Hono was designed from the ground up to thrive in modern, distributed environments. It ships with zero dependencies, has a tiny footprint (around 14KB minified), and achieves exceptional throughput even in cold-start scenarios.
Why Hono Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Understanding Hono's significance requires looking at the landscape it addresses. Modern applications increasingly run on the edge — close to users — using platforms like Cloudflare Workers, Vercel Edge Functions, or Deno Deploy. These environments impose strict constraints: minimal cold-start time, limited memory, and the need for Web Standard APIs (Request/Response).
Here's what makes Hono stand out:
1. Multi-Runtime Support
Hono runs identically on Cloudflare Workers, Deno, Bun, Node.js, AWS Lambda, and even browser service workers. This means you can write your API once and deploy it anywhere without vendor lock-in. The framework abstracts away runtime-specific details while giving you access to each platform's unique capabilities when needed.
2. Blazing Fast Performance
Hono's router uses a highly optimized RegExp-based trie structure that matches routes with O(log n) complexity. Benchmarks consistently show Hono outperforming alternatives like Express, Fastify, and even some native server implementations. For edge functions where every millisecond counts, this speed translates directly to lower latency and reduced compute costs.
3. Tiny Bundle Size
The entire framework is approximately 14KB minified and gzipped. For serverless and edge deployments where bundle size affects cold-start times, this is a massive advantage. You can include Hono in your project without worrying about bloat.
4. Web Standard APIs
Hono embraces the Web Standard Request/Response APIs. This means handlers receive standard Request objects and return standard Response objects — the same APIs used by service workers, the Fetch API, and edge runtimes. This alignment future-proofs your code and makes it portable across any JavaScript environment that supports these standards.
5. Rich Middleware and Utility Ecosystem
Despite its small core, Hono offers a comprehensive set of built-in utilities: CORS handling, JWT authentication, cookie management, body parsing, streaming, and more. There's also a thriving ecosystem of third-party middleware and adapters for ORMs, validation libraries, and monitoring tools.
Getting Started: Your First Hono Application
Let's begin with a minimal Hono application. First, install Hono in your project:
npm install hono
Or with Bun:
bun add hono
Now create a basic server. Here's a complete example that runs on Node.js:
// server.ts
import { Hono } from 'hono'
import { serve } from '@hono/node-server'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello, Hono! 🔥')
})
app.get('/api/health', (c) => {
return c.json({ status: 'ok', timestamp: Date.now() })
})
serve(app, { port: 3000 })
console.log('Server running at http://localhost:3000')
Let's break down what's happening here. We create a new Hono instance, define routes using the familiar HTTP method verbs (get, post, put, delete, etc.), and each handler receives a Context object — conventionally named c — that provides methods for constructing responses. The c.text() method returns a plain text response, while c.json() returns JSON with the correct content-type header.
The @hono/node-server package adapts Hono's Web Standard interface to Node.js's HTTP server. For other runtimes, the setup differs slightly, which we'll explore next.
Deploying Across Different Runtimes
One of Hono's superpowers is running the same application code across multiple platforms. Here's how to adapt the same app for different environments:
Cloudflare Workers
// index.ts — Cloudflare Workers entry point
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => {
return c.text('Hello from the edge! 🌍')
})
app.get('/api/user/:id', (c) => {
const userId = c.req.param('id')
return c.json({ id: userId, name: 'Alice' })
})
export default app
For Cloudflare Workers, you simply export the Hono app as the default export. The Workers runtime calls it directly with the incoming Request. No adapter needed.
Deno
// main.ts — Deno entry point
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello from Deno! 🦕'))
Deno.serve(app.fetch)
Deno's Deno.serve() accepts a fetch-compatible handler, and Hono's app.fetch is exactly that — a function that takes a Request and returns a Response.
Bun
// index.ts — Bun entry point
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello from Bun! 🍞'))
Bun.serve({
port: 3000,
fetch: app.fetch
})
Bun's native server also accepts a fetch handler, making Hono integration seamless.
AWS Lambda
// lambda.ts
import { Hono } from 'hono'
import { handle } from 'hono/aws-lambda'
const app = new Hono()
app.get('/', (c) => c.text('Hello from Lambda! ☁️'))
export const handler = handle(app)
Hono provides built-in adapters for AWS Lambda, turning API Gateway events into standard Request objects automatically.
Routing Deep Dive
Hono's routing system is one of its most powerful features. It supports static routes, path parameters, wildcards, regex patterns, and grouped routes with shared prefixes and middleware.
Path Parameters and Pattern Matching
const app = new Hono()
// Named parameter
app.get('/users/:id', (c) => {
const id = c.req.param('id')
return c.json({ userId: id })
})
// Multiple parameters
app.get('/posts/:category/:slug', (c) => {
const { category, slug } = c.req.param()
return c.json({ category, slug })
})
// Wildcard matching
app.get('/files/*', (c) => {
// c.req.param('*') returns everything after /files/
const path = c.req.param('*')
return c.text(`Requested file path: ${path}`)
})
// Regex pattern
app.get('/items/:id{[0-9]+}', (c) => {
// Only matches if :id is numeric
const id = c.req.param('id')
return c.text(`Item ID: ${id}`)
})
Path parameters are accessible via c.req.param(). The * wildcard captures everything after the preceding path segment. Regex constraints let you restrict parameter values at the route level, preventing invalid requests from reaching your handler logic.
Grouped Routes
Route groups allow you to organize related endpoints and apply shared middleware:
const app = new Hono()
// Group routes under /api/v1
const v1 = new Hono()
v1.get('/users', (c) => c.json({ version: 'v1', users: [] }))
v1.get('/posts', (c) => c.json({ version: 'v1', posts: [] }))
// Group routes under /api/v2
const v2 = new Hono()
v2.get('/users', (c) => c.json({ version: 'v2', users: [], meta: {} }))
v2.get('/posts', (c) => c.json({ version: 'v2', posts: [], pagination: {} }))
// Mount groups with prefixes
app.route('/api/v1', v1)
app.route('/api/v2', v2)
// You can also nest groups directly
app.basePath('/api')
.route('/v1', v1)
.route('/v2', v2)
Each Hono instance is a complete sub-application. This composability makes it easy to structure large codebases, version APIs, or separate public and admin endpoints.
Working with the Context Object
The Context object (c) is the heart of every Hono handler. It provides everything you need to read the incoming request and construct the outgoing response. Let's explore its capabilities in detail:
Request Access
app.post('/submit', async (c) => {
// Access the raw Request object
const request = c.req.raw
// Get headers
const contentType = c.req.header('Content-Type')
const userAgent = c.req.header('User-Agent')
// Query parameters
const searchTerm = c.req.query('q')
const page = c.req.query('page') || '1'
// Multiple query values for same key
const tags = c.req.queries('tag') // returns string[] | undefined
// Path parameters (as seen earlier)
const id = c.req.param('id')
// Parse body
const body = await c.req.json()
const formData = await c.req.parseBody()
const text = await c.req.text()
return c.json({ received: body })
})
Response Construction
app.get('/responses', (c) => {
// Text response
// return c.text('Hello')
// JSON with automatic serialization
return c.json({ message: 'Success', data: [1, 2, 3] })
})
app.get('/custom-response', (c) => {
// HTML response
return c.html('Welcome
')
// Set custom status and headers
return c.json({ error: 'Not found' }, 404, {
'X-Custom-Header': 'value'
})
})
app.get('/redirect', (c) => {
// Redirect
return c.redirect('/dashboard', 302)
})
app.get('/stream', (c) => {
// Stream response
const stream = new ReadableStream({
start(controller) {
controller.enqueue('chunk 1\n')
controller.enqueue('chunk 2\n')
controller.close()
}
})
return c.stream(stream)
})
The Context also provides a c.env property for accessing environment variables and bindings (critical for Cloudflare Workers KV, D1, R2, etc.) and c.var for sharing typed variables between middleware and handlers.
Middleware: Extending Your Application
Middleware in Hono are functions that wrap handlers, allowing you to intercept requests and responses for logging, authentication, error handling, and more. Hono's middleware pattern is incredibly clean:
Built-in Middleware
Hono ships with several essential middleware functions:
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { prettyJSON } from 'hono/pretty-json'
import { timing } from 'hono/timing'
const app = new Hono()
// Apply globally
app.use('*', logger())
app.use('*', cors({
origin: 'https://myapp.com',
allowMethods: ['GET', 'POST', 'PUT'],
allowHeaders: ['Content-Type', 'Authorization'],
maxAge: 86400
}))
app.use('*', prettyJSON())
app.use('*', timing())
app.get('/', (c) => {
return c.json({ hello: 'world' })
})
The logger middleware logs each request with method, path, status, and duration. cors handles Cross-Origin Resource Sharing with configurable options. prettyJSON formats JSON responses with indentation (great for development). timing adds an X-Response-Time header.
Custom Middleware
Writing your own middleware is straightforward. A middleware function receives the context and a next function:
import { Hono, type MiddlewareHandler } from 'hono'
const app = new Hono()
// Authentication middleware
const authMiddleware: MiddlewareHandler = async (c, next) => {
const authHeader = c.req.header('Authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401)
}
const token = authHeader.slice(7)
try {
// Verify token (example using a simple check)
const decoded = JSON.parse(atob(token))
c.set('user', decoded) // Store user info for downstream handlers
await next()
} catch {
return c.json({ error: 'Invalid token' }, 401)
}
}
// Timing middleware
const timingMiddleware: MiddlewareHandler = async (c, next) => {
const start = Date.now()
await next()
const duration = Date.now() - start
c.header('X-Response-Time', `${duration}ms`)
}
// Apply to specific routes
app.get('/public', (c) => c.text('Public endpoint'))
app.use('/admin/*', authMiddleware)
app.get('/admin/dashboard', (c) => {
const user = c.get('user')
return c.json({ message: `Welcome, ${user.name}` })
})
The c.set() and c.get() methods allow middleware to pass data to subsequent handlers. This is typed in TypeScript, giving you full type safety across your middleware chain.
Conditional Middleware
app.get('/conditional', async (c, next) => {
// Only apply logic for certain conditions
if (c.req.query('debug') === 'true') {
console.log('Debug mode enabled for this request')
}
await next()
}, (c) => {
return c.text('Response after conditional middleware')
})
Hono allows multiple handler functions in a single route definition. Each can call next() to pass control to the next handler in the chain, creating a pipeline specific to that route.
Validation with Zod and the Validator Middleware
Input validation is critical for API security and reliability. Hono integrates beautifully with Zod through its built-in validator middleware:
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
// Define schemas
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional()
})
const querySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20)
})
const paramSchema = z.object({
id: z.string().uuid()
})
// Apply validation
app.post('/users',
zValidator('json', userSchema),
(c) => {
// c.req.valid('json') returns the validated data with correct types
const user = c.req.valid('json')
return c.json({ created: user, id: crypto.randomUUID() }, 201)
}
)
app.get('/items',
zValidator('query', querySchema),
(c) => {
const { page, limit } = c.req.valid('query')
// TypeScript knows page and limit are numbers
return c.json({ page, limit, items: [] })
}
)
app.get('/users/:id',
zValidator('param', paramSchema),
(c) => {
const { id } = c.req.valid('param')
return c.json({ userId: id })
}
)
When validation fails, Hono automatically returns a 400 Bad Request response with detailed error information. The validated data is accessible via c.req.valid() with full TypeScript type inference. This pattern eliminates boilerplate validation code and ensures your handlers only receive clean, typed data.
Custom Validation Error Handling
app.onError((err, c) => {
if (err instanceof z.ZodError) {
return c.json({
error: 'Validation failed',
issues: err.issues.map(i => ({
path: i.path.join('.'),
message: i.message
}))
}, 400)
}
return c.json({ error: 'Internal server error' }, 500)
})
// Custom validator with specific error formatting
app.post('/custom-validation',
zValidator('json', userSchema, (result, c) => {
if (!result.success) {
return c.json({
success: false,
errors: result.error.issues
}, 422)
}
}),
(c) => {
const user = c.req.valid('json')
return c.json({ success: true, user })
}
)
The validator middleware accepts an optional callback that fires on validation failure, letting you customize error responses while keeping the success path clean.
Error Handling Strategies
Hono provides a centralized error handling mechanism through app.onError. This catches both thrown errors and explicitly returned error responses:
import { Hono, HTTPException } from 'hono'
const app = new Hono()
// Throw HTTPException for controlled errors
app.get('/protected', (c) => {
const token = c.req.header('Authorization')
if (!token) {
throw new HTTPException(401, { message: 'Missing authorization token' })
}
return c.text('Access granted')
})
app.get('/not-found-example', (c) => {
throw new HTTPException(404, { message: 'Resource not found' })
})
// Global error handler
app.onError((err, c) => {
if (err instanceof HTTPException) {
// Handle known HTTP exceptions
return c.json({
error: err.message,
status: err.status
}, err.status)
}
// Log unexpected errors
console.error('Unhandled error:', err)
// Return generic 500 for unknown errors
return c.json({
error: 'Internal Server Error',
// Only include details in development
...(process.env.NODE_ENV === 'development' && { detail: err.message })
}, 500)
})
// 404 handler for unmatched routes
app.notFound((c) => {
return c.json({
error: 'Not Found',
path: c.req.path
}, 404)
})
The HTTPException class is a clean way to throw structured HTTP errors from anywhere in your application. The app.notFound handler catches all requests that don't match any route, giving you a consistent 404 response format.
Hono RPC: Type-Safe Client Generation
One of Hono's most innovative features is its RPC (Remote Procedure Call) system. It allows you to define your API's contract on the server and automatically generate a type-safe client — eliminating the need for manual fetch calls with string URLs and arbitrary type assertions.
Server-Side: Defining the Contract
// server.ts
import { Hono } from 'hono'
const app = new Hono()
// Define routes with typed responses
const routes = app
.get('/users', (c) => {
return c.json([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' }
])
})
.post('/users', (c) => {
// Handler for creating a user
return c.json({ created: true }, 201)
})
.get('/users/:id', (c) => {
const id = c.req.param('id')
return c.json({ id, name: 'Alice', email: 'alice@example.com' })
})
// Export the type for client use
export type AppType = typeof routes
export default app
Client-Side: Using the Generated Client
// client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'
// Create typed client
const client = hc('http://localhost:3000')
// Now every call is fully type-safe!
async function fetchUsers() {
// TypeScript knows the response structure
const res = await client.users.$get()
const users = await res.json()
// users is typed as Array<{id: string, name: string, email: string}>
console.log(users.map(u => u.name))
}
async function getUser(id: string) {
const res = await client.users[':id'].$get({ param: { id } })
const user = await res.json()
console.log(user.email) // Fully typed!
}
async function createUser() {
const res = await client.users.$post()
// res.status is available, json() returns the typed body
if (res.ok) {
const body = await res.json()
console.log(body.created) // typed as boolean
}
}
The RPC client uses $get, $post, $put, $delete methods (prefixed with $ to avoid collisions with property names). Path parameters are passed via the param option, query parameters via query, and request bodies via the JSON argument. Everything is type-checked at compile time — misspell a parameter or access a non-existent property, and TypeScript will catch it immediately.
RPC with Validation
Combining RPC with Zod validation gives you end-to-end type safety from the database to the UI:
// server.ts with validation
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
const app = new Hono()
const createUserSchema = z.object({
name: z.string(),
email: z.string().email()
})
const routes = app.post('/users',
zValidator('json', createUserSchema),
(c) => {
const data = c.req.valid('json')
// data is fully typed here
return c.json({ id: '123', ...data }, 201)
}
)
export type AppType = typeof routes
// client.ts — fully typed, including validation constraints
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc('http://localhost:3000')
async function createTypedUser() {
const res = await client.users.$post({
json: {
name: 'Charlie',
email: 'charlie@example.com'
// TypeScript enforces the shape!
}
})
const user = await res.json()
// user is typed as { id: string, name: string, email: string }
}
This pattern is revolutionary for full-stack TypeScript development. Changes to the server schema automatically propagate type errors in the client, catching bugs at build time rather than runtime.
Advanced Patterns and Techniques
JSX Rendering for Server-Side HTML
Hono supports JSX out of the box, enabling server-side rendering without additional frameworks:
// tsconfig.json needs: "jsx": "react-jsx", "jsxImportSource": "hono/jsx"
import { Hono } from 'hono'
import { html } from 'hono/html'
const app = new Hono()
// Define a layout component
const Layout = ({ title, children }: { title: string; children: any }) => {
return (
{title}
My App
{children}
Welcome to Hono JSX!
This is rendered on the server.
- Fast
- Type-safe
- No client-side JS needed
)
})
app.get('/about', (c) => {
return c.html(
About This App
Built with Hono's JSX engine.
)
})
Hono's JSX implementation is lightweight and designed for server rendering. It escapes HTML automatically, preventing XSS vulnerabilities. For interactive client-side functionality, you can pair it with HTMX, Alpine.js, or a full React/Vue integration.
Streaming and Server-Sent Events
app.get('/events', (c) => {
const stream = new ReadableStream({
start(controller) {
// Send initial event
controller.enqueue('data: {"message": "Connected"}\n\n')
let count = 0
const interval = setInterval(() => {
count++
controller.enqueue(`data: {"count": ${count}}\n\n`)
if (count >= 10) {
clearInterval(interval)
controller.enqueue('data: {"message": "Done"}\n\n')
controller.close()
}
}, 1000)
// Clean up on abort
c.req.raw.signal.addEventListener('abort', () => {
clearInterval(interval)
})
}
})
return c.stream(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
})
})
Streaming is essential for real-time features. Hono's c.stream() method handles the streaming response correctly, and the c.req.raw.signal lets you detect client disconnections to clean up resources.
WebSocket Support
app.get('/ws', (c) => {
// Upgrade the connection
const { 0: client, 1: server } = new WebSocketPair()
server.addEventListener('message', (event) => {
console.log('Received:', event.data)
server.send(`Echo: ${event.data}`)
})
server.addEventListener('close', () => {
console.log('Client disconnected')
})
return new Response(null, { status: 101, webSocket: client })
})
Hono supports WebSocket upgrades, enabling bidirectional communication for chat apps, live dashboards, and collaborative features. The pattern works on runtimes that support WebSocket upgrades (Cloudflare Workers, Deno, Bun).
Middleware for Rate Limiting
// A simple in-memory rate limiter
const rateLimiter = (limit: number, windowMs: number): MiddlewareHandler => {
const store = new Map()
// Clean up expired entries periodically
setInterval(() => {
const now = Date.now()
for (const [key, entry] of store) {
if (entry.resetAt < now) {
store.delete(key)
}
}
}, windowMs)
return async (c, next) => {
const ip = c.req.header('CF-Connecting-IP') || 'unknown'
const now = Date.now()
let entry = store.get(ip)
if (!entry || entry.resetAt < now) {
entry = { count: 0, resetAt: now + windowMs }
store.set(ip, entry)
}
entry.count++
if (entry.count > limit) {
return c.json({ error: 'Too many requests' }, 429)
}
c.header('X-RateLimit-Remaining', String(Math.max(0, limit - entry.count)))
await next()
}
}
// Apply to API routes
app.use('/api/*', rateLimiter(100, 60000)) // 100 requests per minute
This pattern demonstrates how middleware can implement cross-cutting concerns like rate limiting. In production, you'd likely use a distributed store (like Cloudflare KV or Redis) rather than in-memory storage.
Testing Hono Applications
Hono's design makes it exceptionally testable. Since each Hono instance is a self-contained request handler, you can test it without spinning up an actual server:
// __tests__/app.test.ts
import { Hono } from 'hono'
import { testClient } from 'hono/testing'
import { describe, it, expect } from 'vitest'
// Create the app
const app = new Hono()
.get('/hello', (c) => c.json({ message: 'Hello, world!' }))
.post('/echo', async (c) => {
const body = await c.req.json()
return c.json(body)
})
// testClient creates a client that directly invokes the app
const client = testClient(app)
describe('App', () => {
it('should return hello message', async () => {
const res = await client.hello.$get()
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toEqual({ message: 'Hello, world!' })
})
it('should echo POST body', async () => {
const res = await client.echo.$post({
json: { name: 'Test' }
})
expect(res.status).toBe(200)
const data = await res.json()
expect(data).toEqual({ name: 'Test' })
})
it('should return 404 for unknown routes', async () => {
const res = await client['nonexistent'].$get()
expect(res.status).toBe(404)
})
})
The testClient utility creates a client that calls your app's fetch function directly, bypassing network overhead. This gives you lightning-fast, deterministic tests. Combine with Vitest or Jest for a complete testing setup.
Testing Middleware in Isolation
// Testing middleware independently
import { Hono } from 'hono'
import { testClient } from 'hono/testing'
describe('Auth Middleware', () => {
const setupApp = () => {
const app = new Hono()
app.use('/protected/*', async (c, next) => {
const token = c.req.header('Authorization')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
await next()
})
app.get('/protected/data', (c) => c.json({ secret: 'data' }))
app.get('/public', (c) => c.text('Public'))
return app
}
it('should block unauthenticated requests', async () => {
const client = testClient(setupApp())
const res = await client.protected.data.$get()
expect(res.status).toBe(401)
})
it('should allow authenticated requests', async () => {
const client = testClient(setupApp(), {
headers: { Authorization: 'Bearer valid-token' }
})
const res = await client.protected.data.$get()
expect(res.status).toBe(200)
})
})
The test client accepts initialization options, including headers, making it easy to simulate different request scenarios.
Best Practices for Production Hono Applications
1. Structure Your Application with Modular Routers
Break your application into feature-based modules, each as a separate Hono instance:
// routes/users.ts
import { Hono } from 'hono'
export const usersRouter = new Hono()
.get('/', (c) => c.json({ users: [] }))
.get('/:id', (c) => c.json({ id: c.req.param('id') }))
.post