Hono Idioms and Patterns
Core Philosophy
Hono rewards thin handlers, middleware composition, multi-runtime portability, and end-to-end type safety. Idiomatic Hono = typed routes, validator middleware, RPC client — no code generation needed.
Scope: Hono-specific patterns. For TypeScript fundamentals: @.agents/skills/typescript-idioms/SKILL.md. For project structure: @.agents/skills/hono-idioms/references/project-structure.md.
Loading guard / auto-detection: Hono has no canonical marker file, so it is not reliably auto-detected by file glob (only
wrangler.toml— the Cloudflare Workers case — triggers this skill directly). For Node.js / Bun / Deno Hono projects, co-load this skill alongside@.agents/skills/typescript-idioms/SKILL.mdwheneverhonoappears inpackage.json. Do NOT load this skill for Express, Fastify, or NestJS — it is Hono-only.
When to Load References
Load these before writing code in the matching context — not after.
| Situation | Reference to Load |
|---|---|
| Starting a Hono project or reviewing file layout | references/project-structure.md |
| TypeScript type system, async, Zod, error types | @.agents/skills/typescript-idioms/SKILL.md (always co-load) |
Zod schemas / boundary validation (zValidator) |
@.agents/skills/typescript-idioms/references/zod-patterns.md |
| Async / I/O / coercion / security pitfalls | @.agents/skills/typescript-idioms/references/ts-patterns-and-anti-patterns.md |
| Shared tooling (ESLint, Prettier, Vitest, tsconfig) | @.agents/skills/typescript-idioms/references/recommended-dependencies.md |
Router and Route Organization
Create apps with
new Hono()and method routing:// ✅ Group by resource, compose with app.route() const tasks = new Hono() .get('/', listTasks) .post('/', createTask) .get('/:id', getTask) .put('/:id', updateTask) .delete('/:id', deleteTask); const app = new Hono() .route('/api/tasks', tasks) .route('/api/users', users);Use
app.route('/prefix', subApp)to compose sub-routers — each feature exports its ownHonoinstance.app.basePath('/api/v1')for API versioning — applied once at root level.app.all('*', handler)for catch-all fallback routes.Path parameters use
:namesyntax — accessed viac.req.param('name').
Context (c)
Response helpers — always use typed helpers, never raw
Response:// ✅ c.json(), c.text(), c.html(), c.redirect(), c.notFound() app.get('/tasks/:id', async (c) => { const task = await taskService.find(c.req.param('id')); return c.json(task); });Request data:
c.req.param('id')(path),c.req.query('page')(query string),c.req.header('Authorization')(header),await c.req.json()(body — preferzValidatorinstead).Request-scoped typed variables with
c.set()/c.get():type Env = { Variables: { userId: string; requestId: string } }; const app = new Hono<Env>(); app.use(async (c, next) => { c.set('requestId', crypto.randomUUID()); await next(); }); app.get('/me', (c) => c.json({ id: c.get('userId') })); // fully typed
Middleware
Built-in middleware —
cors(),logger(),secureHeaders(),compress(),timing(),prettyJSON()(each imported fromhono/<name>):app.use('*', logger(), secureHeaders(), compress());Global vs scoped middleware:
// ✅ Global — applies to all routes app.use('*', cors()); // ✅ Scoped — applies only to /api/* routes app.use('/api/*', authMiddleware);Custom middleware with
createMiddleware<>():import { createMiddleware } from 'hono/factory'; type AuthEnv = { Variables: { userId: string } }; const authMiddleware = createMiddleware<AuthEnv>(async (c, next) => { const token = c.req.header('Authorization')?.replace('Bearer ', ''); if (!token) throw new HTTPException(401, { message: 'Unauthorized' }); const payload = await verifyToken(token); c.set('userId', payload.sub); await next(); });Middleware ordering matters — auth before route handlers, logging outermost.
Validation
@hono/zod-validatorfor type-safe request validation:import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; const CreateTaskSchema = z.object({ title: z.string().min(1).max(200), priority: z.enum(['low', 'medium', 'high']), }); // ✅ Compose multiple validators — validated data is fully typed app.post('/tasks', zValidator('json', CreateTaskSchema), async (c) => { const body = c.req.valid('json'); // { title: string; priority: 'low'|'medium'|'high' } const task = await taskService.create(body); return c.json(task, 201); }); app.get('/tasks/:id', zValidator('param', z.object({ id: z.string().uuid() })), async (c) => { const task = await taskService.find(c.req.valid('param').id); return c.json(task); });Validate all input sources:
zValidator('json', ...),zValidator('param', ...),zValidator('query', ...),zValidator('header', ...).
Error Handling
app.onError()for global error handling:import { HTTPException } from 'hono/http-exception'; app.onError((err, c) => { if (err instanceof HTTPException) { return c.json({ error: err.message }, err.status); } console.error(err); return c.json({ error: 'Internal Server Error' }, 500); });HTTPExceptionfor typed HTTP errors:// ✅ Throw in handlers or middleware — caught by onError if (!task) throw new HTTPException(404, { message: `Task ${id} not found` });app.notFound()for custom 404 handling:app.notFound((c) => c.json({ error: 'Not Found' }, 404));
RPC Client
- End-to-end type-safe API calls — no code generation:
// server.ts — chain routes and export the type const routes = app .get('/tasks', async (c) => c.json(await taskService.list())) .post('/tasks', zValidator('json', CreateTaskSchema), async (c) => { return c.json(await taskService.create(c.req.valid('json')), 201); }); export type AppType = typeof routes; // client.ts — fully typed, changes propagate compile errors automatically import { hc } from 'hono/client'; import type { AppType } from './server'; const client = hc<AppType>('http://localhost:3000'); const res = await client.tasks.$post({ json: { title: 'New', priority: 'high' } }); const task = await res.json(); // fully typed
Multi-Runtime
Only the entry point differs — all handler/middleware code is runtime-agnostic:
// Node.js: serve({ fetch: app.fetch, port: 3000 }) // @hono/node-server // Bun / CF Workers: export default app; // Deno: Deno.serve(app.fetch);Never use runtime-specific APIs in handlers — isolate them in
platform/adapters.
Testing
For universal testing principles, see
.agents/rules/testing-strategy.md. Below: Hono-specific patterns only. Test-file naming:*.test.tsco-located next to source (matches the generic-TS convention; see@.agents/skills/typescript-idioms/references/project-structure.md§Test Organization for the cross-framework rule).
app.request()— test handlers without starting a server:import { describe, it, expect } from 'vitest'; import { app } from './app'; describe('GET /api/tasks/:id', () => { it('returns 200 with task data', async () => { const res = await app.request('/api/tasks/abc-123'); expect(res.status).toBe(200); expect((await res.json()).id).toBe('abc-123'); }); it('returns 404 for unknown task', async () => { const res = await app.request('/api/tasks/unknown'); expect(res.status).toBe(404); }); }); // POST with body const res = await app.request('/api/tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Test', priority: 'high' }), });Mock services via dependency injection — create the
Honoapp with test doubles in the test setup, not by mocking modules.
Streaming
streamTextfor plain-text / LLM token streaming:import { streamText } from 'hono/streaming'; app.get('/stream/tokens', (c) => streamText(c, async (stream) => { stream.onAbort(() => console.log('client disconnected')); for (const chunk of ['Hello', ' ', 'World']) { await stream.write(chunk); await stream.sleep(100); } }) );streamSSEfor Server-Sent Events:import { streamSSE } from 'hono/streaming'; app.get('/sse', (c) => streamSSE(c, async (stream) => { stream.onAbort(() => cleanup()); let id = 0; while (true) { await stream.writeSSE({ data: JSON.stringify({ ts: Date.now() }), event: 'tick', id: String(id++), }); await stream.sleep(1000); } }) );streamfor binary or NDJSON (newline-delimited JSON):import { stream } from 'hono/streaming'; app.get('/stream/tasks', (c) => stream(c, async (stream) => { stream.onAbort(() => cleanup()); const tasks = await taskService.list(); for (const task of tasks) { await stream.write(JSON.stringify(task) + '\n'); } }) );Always call
stream.onAbort()to release resources when the client disconnects — without it, the generator keeps running after the connection drops.
Anti-Patterns
- ❌ Business logic in handlers — extract to a service/logic layer; handlers only validate, delegate, respond
- ❌ Manual
JSON.parse(await c.req.text())— usec.req.json()orzValidator('json', schema)for type-safe parsing - ❌ Untyped context variables — always use
Hono<{ Variables: ... }>generics forc.set()/c.get() - ❌ Ignoring middleware ordering — auth must run before route handlers; logging outermost
- ❌
app.use()without path scope — useapp.use('/api/*', ...)when middleware should be scoped, not global - ❌ Runtime-specific code in handlers — use adapters at the entry point only; handlers must be portable
Formatting and Static Analysis
Same tooling as TypeScript. See @.agents/skills/typescript-idioms/SKILL.md.
Related
- Code Idioms and Conventions @.agents/rules/code-idioms-and-conventions.md
- TypeScript Idioms @.agents/skills/typescript-idioms/SKILL.md
- API Design Principles @.agents/rules/api-design-principles.md
- Security Principles @.agents/rules/security-principles.md
- Error Handling Principles @.agents/rules/error-handling-principles.md
- Architectural Patterns @.agents/rules/architectural-pattern.md
- Testing Strategy @.agents/rules/testing-strategy.md
- Logging and Observability Mandate @.agents/rules/logging-and-observability-mandate.md
- Logging Implementation @.agents/skills/logging-implementation/SKILL.md