
Hono
- 63 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
hono is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- hono
- AI & Agent Building
- AI-coding skill
Hono by the numbers
- 63 all-time installs (skills.sh)
- +2 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #6,243 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill honoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 63 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Hono
Overview
Hono is a small, ultrafast web framework built on Web Standards that runs on any JavaScript runtime including Cloudflare Workers, Bun, Deno, Node.js, Vercel, and AWS Lambda. Application code is portable across runtimes; only the entry point and adapter differ per platform.
When to use: Edge-first APIs, Cloudflare Workers services, multi-runtime applications, lightweight REST/RPC servers, middleware-heavy request pipelines, type-safe client-server communication.
When NOT to use: Full-stack SSR frameworks (use Next.js/Remix), heavy ORM-driven monoliths where Express ecosystem maturity matters, applications that need deep Node.js-only APIs without Web Standard equivalents.
Quick Reference
| Pattern | API | Key Points |
|---|---|---|
| Basic routing | app.get('/path', handler) | Supports get, post, put, delete, patch, all |
| Path parameters | app.get('/user/:id', handler) | Access via c.req.param('id') |
| Regex constraints | app.get('/post/:id{[0-9]+}', handler) | Inline regex in path parameter |
| Wildcard | app.get('/files/*', handler) | Matches any sub-path |
| Route grouping | app.route('/api', subApp) | Mount sub-applications |
| Middleware | app.use(middleware()) | Executes in registration order |
| Path middleware | app.use('/auth/*', jwt(...)) | Scope middleware to paths |
| JSON response | c.json({ key: 'value' }) | Sets Content-Type automatically |
| Text response | c.text('hello') | Returns plain text |
| HTML response | c.html('<h1>Hi</h1>') | Returns HTML content |
| Status + header | c.status(201), c.header('X-Key', 'val') | Chain before response |
| Redirect | c.redirect('/new-path', 301) | Default status is 302 |
| Request body | c.req.json(), c.req.parseBody() | JSON or form data parsing |
| Query params | c.req.query('page') | Single query parameter |
| Context variables | c.set('user', data) / c.get('user') | Type-safe middleware data passing |
| Zod validation | zValidator('json', schema) | @hono/zod-validator package |
| RPC client | hc<AppType>(url) | End-to-end type-safe API calls |
| Error handler | app.onError((err, c) => ...) | Global error handling |
| Not found | app.notFound((c) => ...) | Custom 404 handler |
| Environment | c.env.BINDING_KEY | Access runtime bindings/env vars |
| Adapter helper | env(c) from hono/adapter | Unified env access across runtimes |
| WebSocket | upgradeWebSocket() from runtime adapter | WebSocket helper with RPC support |
| Test client | testClient(app) from hono/testing | Type-safe testing without HTTP server |
| JSX streaming | jsxRenderer({ stream: true }) | SSR streaming with Suspense support |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
| Registering middleware after handlers | Register middleware before the handlers it should apply to |
Using app.route() without chaining for RPC types | Chain route methods (app.route('/a', a).route('/b', b)) and export typeof routes |
Forgetting export default app for edge runtimes | Cloudflare Workers, Bun, and Deno require the default export |
Using compress() on Cloudflare Workers or Deno Deploy | These platforms compress automatically; skip the middleware |
| Mixing Hono and middleware versions in Deno | Pin all hono imports to the same version to avoid type mismatches |
Wrapping next() in try/catch inside middleware | Hono catches thrown errors and routes to app.onError(); next() never throws |
Defining routes with app.get() but exporting typeof app for RPC | Export the type of the chained route variable, not the app instance |
Using new Hono() without generics for typed context | Pass Hono<{ Variables: MyVars }> to enable typed c.get()/c.set() |
Returning c.json() without status for created resources | Pass status as second argument: c.json(data, 201) |
Not using createMiddleware() for reusable middleware | Import from hono/factory to preserve type inference when extracting middleware |
Delegation
If thezodortypescript-patternsskill is available, delegate schema and type-level tasks to it.
Otherwise, recommend: npx skills add oakoss/agent-skills --skill typescript-patterns>
If a Cloudflare Workers skill is available, delegate platform-specific configuration to it.
References
- Route definitions, path params, groups, and method handlers
- Built-in and custom middleware patterns
- Context object, request parsing, and response helpers
- Zod validator middleware and request validation
- RPC client and end-to-end type safety
- Runtime adapters for Cloudflare, Bun, Node.js, Deno, and more
- Testing with testClient, WebSocket helper, and JSX streaming
Adapters
Hono runs on any JavaScript runtime that supports Web Standards. Application code is identical across platforms; only the entry point differs.
Cloudflare Workers
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Workers!'));
export default app;No adapter needed. Cloudflare Workers natively support the export default pattern.
With Bindings
type Env = {
Bindings: {
MY_KV: KVNamespace;
DB: D1Database;
MY_BUCKET: R2Bucket;
SECRET_KEY: string;
};
};
const app = new Hono<Env>();
app.get('/data', async (c) => {
const value = await c.env.MY_KV.get('key');
return c.json({ value });
});
export default app;Project Setup
npm create hono@latest my-app -- --template cloudflare-workers{
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy"
}
}Bun
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Bun!'));
export default app;Bun natively supports the default export. To customize the port:
export default {
port: 3000,
fetch: app.fetch,
};Project Setup
bun create hono@latest my-appbun run devNode.js
Node.js requires the @hono/node-server adapter:
npm install @hono/node-serverimport { Hono } from 'hono';
import { serve } from '@hono/node-server';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Node.js!'));
serve({
fetch: app.fetch,
port: 3000,
});With Static Files
npm install @hono/node-serverimport { Hono } from 'hono';
import { serve } from '@hono/node-server';
import { serveStatic } from '@hono/node-server/serve-static';
const app = new Hono();
app.use('/static/*', serveStatic({ root: './' }));
serve({ fetch: app.fetch, port: 3000 });Project Setup
npm create hono@latest my-app -- --template nodejsDeno
import { Hono } from 'https://deno.land/x/hono/mod.ts';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Deno!'));
Deno.serve(app.fetch);Or with npm specifier:
import { Hono } from 'npm:hono';
const app = new Hono();
app.get('/', (c) => c.text('Hello from Deno!'));
Deno.serve(app.fetch);Pin all Hono imports to the same version to avoid type mismatches.
Project Setup
deno run -A npm:create-hono@latest my-appVercel
import { Hono } from 'hono';
import { handle } from 'hono/vercel';
const app = new Hono().basePath('/api');
app.get('/hello', (c) => c.json({ message: 'Hello from Vercel!' }));
export const GET = handle(app);
export const POST = handle(app);Project Setup
npm create hono@latest my-app -- --template vercelAWS Lambda
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);With API Gateway Events
import { Hono } from 'hono';
import { handle, type LambdaEvent } from 'hono/aws-lambda';
const app = new Hono();
app.get('/', (c) => {
const event = c.env.event as LambdaEvent;
return c.json({ requestId: event.requestContext?.requestId });
});
export const handler = handle(app);Project Setup
npm create hono@latest my-app -- --template aws-lambdaRuntime Detection
Detect the current runtime at execution time:
import { getRuntimeKey } from 'hono/adapter';
app.get('/runtime', (c) => {
const runtime = getRuntimeKey();
return c.json({ runtime });
});Cross-Runtime Environment Access
Use the env() helper for unified environment variable access:
import { env } from 'hono/adapter';
app.get('/config', (c) => {
const { DATABASE_URL } = env<{ DATABASE_URL: string }>(c);
return c.json({ configured: !!DATABASE_URL });
});Works on Cloudflare Workers (c.env), Node.js (process.env), Bun (Bun.env), Deno (Deno.env), and Vercel.
Starter Templates
| Runtime | Command |
|---|---|
| Cloudflare Workers | npm create hono@latest -- --template cloudflare-workers |
| Cloudflare Pages | npm create hono@latest -- --template cloudflare-pages |
| Bun | bun create hono@latest |
| Node.js | npm create hono@latest -- --template nodejs |
| Deno | deno run -A npm:create-hono@latest |
| Vercel | npm create hono@latest -- --template vercel |
| AWS Lambda | npm create hono@latest -- --template aws-lambda |
Context and Helpers
The Context object (c) is created for each request and available until the response is returned.
Response Helpers
JSON
app.get('/api/user', (c) => {
return c.json({ id: '1', name: 'Alice' });
});
app.post('/api/user', (c) => {
return c.json({ id: '2', name: 'Bob' }, 201);
});Text
app.get('/health', (c) => {
return c.text('OK');
});HTML
app.get('/page', (c) => {
return c.html('<h1>Hello</h1>');
});Redirect
app.get('/old', (c) => c.redirect('/new'));
app.get('/moved', (c) => c.redirect('/new-location', 301));Raw Response
app.get('/custom', (c) => {
return new Response('raw body', {
status: 200,
headers: { 'Content-Type': 'application/octet-stream' },
});
});No Content
app.delete('/item/:id', (c) => {
return c.body(null, 204);
});Status and Headers
app.get('/data', (c) => {
c.status(200);
c.header('X-Request-Id', crypto.randomUUID());
c.header('Cache-Control', 'max-age=3600');
return c.json({ data: 'value' });
});Request Parsing
Path Parameters
app.get('/user/:id', (c) => {
const id = c.req.param('id');
return c.json({ id });
});
app.get('/posts/:postId/comments/:commentId', (c) => {
const { postId, commentId } = c.req.param();
return c.json({ postId, commentId });
});Query Parameters
app.get('/search', (c) => {
const q = c.req.query('q');
const page = c.req.query('page');
return c.json({ q, page });
});
app.get('/filter', (c) => {
const queries = c.req.queries('tag');
return c.json({ tags: queries });
});JSON Body
app.post('/api/items', async (c) => {
const body = await c.req.json();
return c.json({ received: body }, 201);
});Form Data
app.post('/upload', async (c) => {
const body = await c.req.parseBody();
const file = body['file'];
return c.text(`Received: ${typeof file === 'string' ? file : file.name}`);
});Raw Body
app.post('/raw', async (c) => {
const text = await c.req.text();
const buffer = await c.req.arrayBuffer();
return c.text('OK');
});Request Headers
app.get('/check', (c) => {
const auth = c.req.header('Authorization');
const contentType = c.req.header('Content-Type');
return c.json({ auth: !!auth, contentType });
});Request URL and Method
app.all('/info', (c) => {
return c.json({
method: c.req.method,
url: c.req.url,
path: c.req.path,
});
});Context Variables
Pass typed data between middleware and handlers:
type Env = {
Variables: {
user: { id: string; email: string };
requestId: string;
};
};
const app = new Hono<Env>();
app.use(async (c, next) => {
c.set('requestId', crypto.randomUUID());
await next();
});
app.get('/me', (c) => {
const user = c.get('user');
const requestId = c.get('requestId');
return c.json({ user, requestId });
});Values set with c.set() are scoped to the current request.
Environment Bindings
Access platform bindings (Cloudflare KV, D1, R2, secrets) and environment variables:
type Env = {
Bindings: {
DATABASE: D1Database;
KV_STORE: KVNamespace;
API_KEY: string;
};
};
const app = new Hono<Env>();
app.get('/data', async (c) => {
const db = c.env.DATABASE;
const kv = c.env.KV_STORE;
const key = c.env.API_KEY;
return c.json({ key });
});Adapter Helper for Cross-Runtime Env
import { env } from 'hono/adapter';
app.get('/config', (c) => {
const { DATABASE_URL, API_KEY } = env<{
DATABASE_URL: string;
API_KEY: string;
}>(c);
return c.json({ configured: true });
});Works across Cloudflare Workers, Node.js, Bun, Deno, and Vercel.
Renderer
Set a layout for HTML responses:
app.use(async (c, next) => {
c.setRenderer((content) => {
return c.html(`<!DOCTYPE html><html><body>${content}</body></html>`);
});
await next();
});
app.get('/', (c) => {
return c.render('<h1>Hello</h1>');
});Error Object
Access caught errors in app.onError():
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse();
}
return c.json({ error: err.message }, 500);
});HTTPException
Throw HTTP errors from handlers or middleware:
import { HTTPException } from 'hono/http-exception';
app.get('/protected', (c) => {
if (!authorized) {
throw new HTTPException(401, { message: 'Unauthorized' });
}
return c.text('OK');
});Middleware
Middleware Execution Order
Middleware executes in registration order. The code before await next() runs top-down; the code after next() runs bottom-up (onion model):
app.use(async (c, next) => {
console.log('1: before');
await next();
console.log('1: after');
});
app.use(async (c, next) => {
console.log('2: before');
await next();
console.log('2: after');
});
app.get('/', (c) => c.text('handler'));Output order: 1: before -> 2: before -> handler -> 2: after -> 1: after.
Built-in Middleware
Logger
import { logger } from 'hono/logger';
app.use(logger());CORS
import { cors } from 'hono/cors';
app.use('/api/*', cors());
app.use(
'/api/*',
cors({
origin: 'https://example.com',
allowHeaders: ['Content-Type', 'Authorization'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
maxAge: 600,
}),
);For dynamic origins from environment variables:
app.use('/api/*', (c, next) => {
const corsMiddleware = cors({
origin: c.env.ALLOWED_ORIGIN,
});
return corsMiddleware(c, next);
});Basic Auth
import { basicAuth } from 'hono/basic-auth';
app.use(
'/admin/*',
basicAuth({
username: 'admin',
password: 'secret',
}),
);Bearer Auth
import { bearerAuth } from 'hono/bearer-auth';
app.use(
'/api/*',
bearerAuth({
token: 'my-secret-token',
}),
);JWT Auth
import { jwt } from 'hono/jwt';
import { type JwtVariables } from 'hono/jwt';
type Variables = JwtVariables;
const app = new Hono<{ Variables: Variables }>();
app.use(
'/auth/*',
jwt({
secret: 'it-is-very-secret',
}),
);
app.get('/auth/profile', (c) => {
const payload = c.get('jwtPayload');
return c.json(payload);
});Use environment variables for the secret:
app.use('/auth/*', (c, next) => {
const jwtMiddleware = jwt({ secret: c.env.JWT_SECRET });
return jwtMiddleware(c, next);
});Compress
import { compress } from 'hono/compress';
app.use(compress());Skip on Cloudflare Workers and Deno Deploy where compression is automatic.
Secure Headers
import { secureHeaders } from 'hono/secure-headers';
app.use(secureHeaders());Powered By
import { poweredBy } from 'hono/powered-by';
app.use(poweredBy());Scoped Middleware
Apply middleware to specific paths:
app.use(logger());
app.use('/api/*', cors());
app.use('/admin/*', basicAuth({ username: 'admin', password: 'secret' }));Custom Middleware
Inline middleware:
app.use(async (c, next) => {
const start = Date.now();
await next();
const duration = Date.now() - start;
c.header('X-Response-Time', `${duration}ms`);
});Reusable Middleware with createMiddleware
Use createMiddleware from hono/factory for type-safe, reusable middleware:
import { createMiddleware } from 'hono/factory';
type Env = {
Variables: {
user: { id: string; role: string };
};
};
const authMiddleware = createMiddleware<Env>(async (c, next) => {
const token = c.req.header('Authorization');
if (!token) return c.json({ error: 'Unauthorized' }, 401);
c.set('user', { id: '1', role: 'admin' });
await next();
});
const app = new Hono<Env>();
app.use(authMiddleware);
app.get('/me', (c) => {
const user = c.get('user');
return c.json(user);
});Factory Helper for Consistent Typing
Use createFactory to avoid repeating environment types:
import { createFactory } from 'hono/factory';
type Env = {
Bindings: { DATABASE_URL: string };
Variables: { requestId: string };
};
const factory = createFactory<Env>();
const requestIdMiddleware = factory.createMiddleware(async (c, next) => {
c.set('requestId', crypto.randomUUID());
await next();
});
const app = factory.createApp();
app.use(requestIdMiddleware);Error Handling in Middleware
Hono catches thrown errors and routes them to app.onError(). There is no need to wrap next() in try/catch:
app.use(async (c, next) => {
await next();
});
app.onError((err, c) => {
console.error(err);
return c.json({ error: 'Internal Server Error' }, 500);
});Combine Middleware
Combine multiple middleware into one using the combine helper:
import { every, some } from 'hono/combine';
app.use('/api/*', every(cors(), bearerAuth({ token: 'secret' })));
app.use('/webhook/*', some(bearerAuth({ token: 'secret' }), ipRestriction()));every()requires all middleware to pass (AND logic)some()requires at least one to pass (OR logic)
Routing
Basic Routes
import { Hono } from 'hono';
const app = new Hono();
app.get('/', (c) => c.text('GET /'));
app.post('/', (c) => c.text('POST /'));
app.put('/', (c) => c.text('PUT /'));
app.delete('/', (c) => c.text('DELETE /'));
app.patch('/item', (c) => c.text('PATCH /item'));Match Any HTTP Method
app.all('/hello', (c) => c.text('Any method'));Custom and Multiple Methods
app.on('PURGE', '/cache', (c) => c.text('PURGE /cache'));
app.on(['PUT', 'DELETE'], '/post', (c) => c.text('PUT or DELETE /post'));Multiple Paths
app.on('GET', ['/hello', '/ja/hello', '/en/hello'], (c) => c.text('Hello'));Path Parameters
app.get('/user/:name', (c) => {
const name = c.req.param('name');
return c.text(`User: ${name}`);
});
app.get('/posts/:id/comment/:commentId', (c) => {
const { id, commentId } = c.req.param();
return c.json({ postId: id, commentId });
});Optional Parameters
app.get('/api/animal/:type?', (c) => {
const type = c.req.param('type') || 'all';
return c.text(`Animal type: ${type}`);
});Regex Constraints
Inline regex restricts what a parameter matches:
app.get('/post/:date{[0-9]+}/:title{[a-z]+}', (c) => {
const { date, title } = c.req.param();
return c.json({ date, title });
});Wildcards
app.get('/wild/*/card', (c) => c.text('Matched wildcard'));
app.get('/files/*', (c) => c.text('Any file path'));Route Grouping with app.route()
Split applications into modular sub-routers:
// authors.ts
import { Hono } from 'hono';
const app = new Hono()
.get('/', (c) => c.json('list authors'))
.post('/', (c) => c.json('create an author', 201))
.get('/:id', (c) => c.json(`get ${c.req.param('id')}`));
export default app;// books.ts
import { Hono } from 'hono';
const app = new Hono()
.get('/', (c) => c.json('list books'))
.post('/', (c) => c.json('create a book', 201))
.get('/:id', (c) => c.json(`get ${c.req.param('id')}`));
export default app;// index.ts
import { Hono } from 'hono';
import authors from './authors';
import books from './books';
const app = new Hono();
const routes = app.route('/authors', authors).route('/books', books);
export default app;
export type AppType = typeof routes;Chain .route() calls and export the type of the chained result for RPC type inference.
Base Path
Set a prefix for all routes in an app instance:
const api = new Hono().basePath('/api/v1');
api.get('/users', (c) => c.json([]));This registers the handler at /api/v1/users.
Handler Execution Order
Handlers and middleware execute in registration order. When a handler returns a response, processing stops. Place middleware before handlers and fallback handlers last:
app.use(logger());
app.get('/specific', (c) => c.text('matched'));
app.all('*', (c) => c.text('fallback', 404));Multiple Handlers per Route
Pass multiple handlers to a single route definition. Each handler can call next() or return a response:
app.get(
'/protected',
async (c, next) => {
const token = c.req.header('Authorization');
if (!token) return c.text('Unauthorized', 401);
await next();
},
(c) => c.text('Secret content'),
);Error Handling
app.onError((err, c) => {
console.error(err);
return c.json({ error: 'Internal Server Error' }, 500);
});
app.notFound((c) => {
return c.json({ error: 'Not Found' }, 404);
});RPC Client
Hono's RPC mode provides end-to-end type safety between server and client. The server exports route types, and the hc client infers endpoints, parameters, and response types automatically.
Server Setup
Chain route methods and export the type of the result:
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono();
const route = app
.get('/api/hello', (c) => {
return c.json({ message: 'Hello!' });
})
.post(
'/api/posts',
zValidator(
'json',
z.object({
title: z.string(),
body: z.string(),
}),
),
(c) => {
const data = c.req.valid('json');
return c.json({ id: '1', ...data }, 201);
},
)
.get('/api/posts/:id', (c) => {
const id = c.req.param('id');
return c.json({ id, title: 'Post', body: 'Content' });
});
export type AppType = typeof route;
export default app;Export typeof route (the chained result), not typeof app.
Client Usage
import { hc } from 'hono/client';
import { type AppType } from './server';
const client = hc<AppType>('http://localhost:8787');
const helloRes = await client.api.hello.$get();
const helloData = await helloRes.json();
const createRes = await client.api.posts.$post({
json: {
title: 'New Post',
body: 'Post content',
},
});
const createData = await createRes.json();
const postRes = await client.api.posts[':id'].$get({
param: { id: '1' },
});
const postData = await postRes.json();The client provides autocompletion for all endpoints, methods, parameters, and response types.
Path Parameters
const route = app.get(
'/posts/:id',
zValidator('query', z.object({ page: z.coerce.number().optional() })),
(c) => {
return c.json({ title: 'Post', body: 'Content' });
},
);
const res = await client.posts[':id'].$get({
param: { id: '123' },
query: { page: '1' },
});Path and query parameters are passed as strings in the client.
Grouped Routes
Structure larger applications with separate routers:
// authors.ts
import { Hono } from 'hono';
const app = new Hono()
.get('/', (c) => c.json([]))
.post('/', (c) => c.json({ id: '1' }, 201))
.get('/:id', (c) => c.json({ id: c.req.param('id') }));
export default app;// books.ts
import { Hono } from 'hono';
const app = new Hono()
.get('/', (c) => c.json([]))
.get('/:id', (c) => c.json({ id: c.req.param('id') }));
export default app;// index.ts
import { Hono } from 'hono';
import authors from './authors';
import books from './books';
const app = new Hono();
const routes = app.route('/authors', authors).route('/books', books);
export default app;
export type AppType = typeof routes;// client.ts
import { hc } from 'hono/client';
import { type AppType } from './index';
const client = hc<AppType>('http://localhost:8787');
const authorsRes = await client.authors.$get();
const bookRes = await client.books[':id'].$get({ param: { id: '1' } });Monorepo Configuration
For RPC types to work in a monorepo, both client and server tsconfig.json files must have:
{
"compilerOptions": {
"strict": true
}
}Performance Optimization
For large APIs, type inference can slow down the IDE. Pre-calculate types at compile time:
import { hc } from 'hono/client';
import { type AppType } from './server';
const hcWithType = (...args: Parameters<typeof hc<AppType>>) =>
hc<AppType>(...args);
const client = hcWithType('http://localhost:8787');Run tsc to compile the server app with the client. This lets tsc handle type instantiation at build time, keeping the IDE fast.
Response Parsing
The client returns standard Response objects. Parse them with .json(), .text(), etc.:
const res = await client.api.posts.$get();
if (res.ok) {
const data = await res.json();
console.log(data.title);
} else {
console.error('Request failed:', res.status);
}Custom Headers and Options
Pass fetch options including custom headers:
const client = hc<AppType>('http://localhost:8787', {
headers: {
Authorization: 'Bearer my-token',
},
});Per-request headers:
const res = await client.api.posts.$get(undefined, {
headers: {
'X-Custom-Header': 'value',
},
});Testing and WebSocket
Testing with testClient
The testClient helper provides a type-safe way to test Hono applications without starting an HTTP server.
Basic Test Setup
import { Hono } from 'hono';
import { testClient } from 'hono/testing';
const app = new Hono()
.get('/api/hello', (c) => c.json({ message: 'Hello!' }))
.post('/api/posts', async (c) => {
const body = await c.req.json();
return c.json({ id: '1', ...body }, 201);
});
const client = testClient(app);Testing GET Requests
import { describe, it, expect } from 'vitest';
describe('GET /api/hello', () => {
it('returns hello message', async () => {
const res = await client.api.hello.$get();
expect(res.status).toBe(200);
const data = await res.json();
expect(data.message).toBe('Hello!');
});
});Testing POST Requests
describe('POST /api/posts', () => {
it('creates a post', async () => {
const res = await client.api.posts.$post({
json: { title: 'Test', body: 'Content' },
});
expect(res.status).toBe(201);
const data = await res.json();
expect(data.title).toBe('Test');
});
});Testing with Path Parameters
const app = new Hono().get('/users/:id', (c) => {
return c.json({ id: c.req.param('id') });
});
const client = testClient(app);
const res = await client.users[':id'].$get({
param: { id: '42' },
});
const data = await res.json();
expect(data.id).toBe('42');Testing with Query Parameters
const app = new Hono().get('/search', (c) => {
return c.json({ q: c.req.query('q') });
});
const client = testClient(app);
const res = await client.search.$get({
query: { q: 'hono' },
});Testing with Environment Variables
type Env = {
Bindings: { API_KEY: string };
};
const app = new Hono<Env>().get('/config', (c) => {
return c.json({ hasKey: !!c.env.API_KEY });
});
const client = testClient(app, { API_KEY: 'test-key' });WebSocket Helper
Hono provides a WebSocket helper through runtime-specific adapters. The helper enables WebSocket upgrades within Hono route handlers.
Cloudflare Workers WebSocket
import { Hono } from 'hono';
import { upgradeWebSocket } from 'hono/cloudflare-workers';
const app = new Hono();
app.get(
'/ws',
upgradeWebSocket((c) => ({
onOpen(_event, ws) {
console.log('Connection opened');
},
onMessage(event, ws) {
ws.send(`Echo: ${event.data}`);
},
onClose() {
console.log('Connection closed');
},
onError(event) {
console.error('WebSocket error:', event);
},
})),
);
export default app;Bun WebSocket
import { Hono } from 'hono';
import { upgradeWebSocket, websocket } from 'hono/bun';
const app = new Hono();
app.get(
'/ws',
upgradeWebSocket((c) => ({
onMessage(event, ws) {
ws.send(`Echo: ${event.data}`);
},
})),
);
export default {
fetch: app.fetch,
websocket,
};When using Bun, export the websocket handler alongside fetch.
Deno WebSocket
import { Hono } from 'hono';
import { upgradeWebSocket } from 'hono/deno';
const app = new Hono();
app.get(
'/ws',
upgradeWebSocket((c) => ({
onMessage(event, ws) {
ws.send(`Echo: ${event.data}`);
},
})),
);
Deno.serve(app.fetch);WebSocket Event Handlers
| Handler | Parameters | Description |
|---|---|---|
onOpen | (event, ws) | Connection established |
onMessage | (event, ws) | Message received from client |
onClose | (event, ws) | Connection closed |
onError | (event) | WebSocket error occurred |
RPC-Mode WebSocket
WebSocket routes support RPC type inference for type-safe client connections.
const wsApp = app.get(
'/ws',
upgradeWebSocket((c) => ({
onMessage(event, ws) {
ws.send(event.data);
},
})),
);
export type WebSocketApp = typeof wsApp;import { hc } from 'hono/client';
import { type WebSocketApp } from './server';
const client = hc<WebSocketApp>('http://localhost:8787');
const socket = client.ws.$ws();
socket.addEventListener('open', () => {
socket.send('Hello from RPC client!');
});JSX Streaming with Suspense
The JSX Renderer middleware supports streaming responses when the stream option is enabled, allowing async components with Suspense boundaries.
Enable Streaming
import { Hono } from 'hono';
import { jsxRenderer } from 'hono/jsx-renderer';
import { Suspense } from 'hono/jsx';
const app = new Hono();
app.use(
'*',
jsxRenderer(
({ children }) => (
<html>
<body>
<h1>SSR Streaming</h1>
{children}
</body>
</html>
),
{ stream: true },
),
);Async Components with Suspense
const AsyncUserList = async () => {
const users = await fetchUsers();
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
};
app.get('/', (c) => {
return c.render(
<Suspense fallback={<div>Loading users...</div>}>
<AsyncUserList />
</Suspense>,
);
});The server streams the fallback immediately, then replaces it with the resolved content when the async component completes.
Validation
Hono integrates with Zod through the @hono/zod-validator package for runtime validation with compile-time type inference.
Installation
npm install @hono/zod-validator zodBasic JSON Body Validation
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono();
const createPostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
published: z.boolean().optional(),
});
app.post('/api/posts', zValidator('json', createPostSchema), (c) => {
const data = c.req.valid('json');
return c.json({ id: '1', ...data }, 201);
});Form Data Validation
const uploadSchema = z.object({
name: z.string(),
email: z.string().email(),
});
app.post('/submit', zValidator('form', uploadSchema), (c) => {
const { name, email } = c.req.valid('form');
return c.json({ name, email });
});Query Parameter Validation
const searchSchema = z.object({
q: z.string().min(1),
page: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
});
app.get('/api/search', zValidator('query', searchSchema), (c) => {
const { q, page, limit } = c.req.valid('query');
return c.json({ q, page: page ?? 1, limit: limit ?? 20 });
});Query and path parameters are always strings. Use z.coerce.number() to convert to numbers.
Path Parameter Validation
const idSchema = z.object({
id: z.string().regex(/^[0-9]+$/),
});
app.get('/api/posts/:id', zValidator('param', idSchema), (c) => {
const { id } = c.req.valid('param');
return c.json({ id });
});Header Validation
const headerSchema = z.object({
'x-api-key': z.string().min(1),
});
app.get('/api/data', zValidator('header', headerSchema), (c) => {
const headers = c.req.valid('header');
return c.json({ authenticated: true });
});Multiple Validators on One Route
Stack validators for different targets:
app.put(
'/api/posts/:id',
zValidator('param', z.object({ id: z.string() })),
zValidator(
'json',
z.object({
title: z.string().min(1),
body: z.string().min(1),
}),
),
(c) => {
const { id } = c.req.valid('param');
const data = c.req.valid('json');
return c.json({ id, ...data });
},
);Custom Error Handling
Pass a hook function as the third argument to customize validation error responses:
app.post(
'/api/items',
zValidator('json', itemSchema, (result, c) => {
if (!result.success) {
return c.json(
{
error: 'Validation failed',
issues: result.error.issues,
},
400,
);
}
}),
(c) => {
const data = c.req.valid('json');
return c.json(data, 201);
},
);If the hook returns a response, it short-circuits the handler. If it returns nothing on success, the handler proceeds.
Reusable Validated Routes
Combine zValidator with route grouping for consistent validation:
const paginationSchema = z.object({
page: z.coerce.number().int().positive().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
});
const withPagination = zValidator('query', paginationSchema);
app.get('/api/posts', withPagination, (c) => {
const { page, limit } = c.req.valid('query');
return c.json({ page: page ?? 1, limit: limit ?? 20 });
});
app.get('/api/comments', withPagination, (c) => {
const { page, limit } = c.req.valid('query');
return c.json({ page: page ?? 1, limit: limit ?? 20 });
});Validation Targets
| Target | zValidator(target, ...) | Access | Description |
|---|---|---|---|
| JSON body | 'json' | c.req.valid('json') | Parsed JSON request body |
| Form data | 'form' | c.req.valid('form') | URL-encoded or multipart form |
| Query params | 'query' | c.req.valid('query') | URL query string |
| Path params | 'param' | c.req.valid('param') | Route path parameters |
| Headers | 'header' | c.req.valid('header') | Request headers |
| Cookies | 'cookie' | c.req.valid('cookie') | Request cookies |
Type Inference
Validated data is fully typed. The handler receives the exact shape defined by the Zod schema:
const schema = z.object({
name: z.string(),
age: z.coerce.number(),
});
app.post('/user', zValidator('json', schema), (c) => {
const data = c.req.valid('json');
return c.json({ name: data.name, age: data.age });
});