Hono API Development
Route chaining → type-safe RPC → end-to-end types.
<when_to_use>
-
Building REST APIs with Hono
-
Type-safe RPC with hono/client
-
OpenAPI documentation with Zod
-
Testing APIs with testClient
-
When user mentions "Hono", "RPC", or "OpenAPI"
NOT for: Bun runtime APIs (use bun-dev), other frameworks (Express, Fastify)
</when_to_use>
<version_notes>
Hono v4+ with @hono/zod-openapi v1.0+ Check hono.dev for latest patterns.
</version_notes>
Route Chaining — Critical Pattern
Type inference flows through method chain. Break chain = lose types.
<route_chaining>
// ✅ Chained routes preserve types const app = new Hono() .get('/users', (c) => c.json({ users: [] })) .get('/users/:id', (c) => { const id = c.req.param('id'); // Typed! return c.json({ id }); }) .post('/users', async (c) => { const body = await c.req.json(); return c.json({ created: true }, 201); });
export type AppType = typeof app; // Full route types!
❌ NEVER break the chain:
const app = new Hono(); app.get('/users', handler1); // Types LOST! app.post('/users', handler2);
Path parameters — typed automatically:
.get('/posts/:id/comments/:commentId', (c) => { const { id, commentId } = c.req.param(); // Both string return c.json({ postId: id, commentId }); })
Query parameters — use Zod for validation:
import { zValidator } from '@hono/zod-validator'; import { z } from 'zod';
const QuerySchema = z.object({ page: z.coerce.number().int().positive().default(1), limit: z.coerce.number().int().positive().max(100).default(20), });
const app = new Hono() .get('/search', zValidator('query', QuerySchema), (c) => { const { page, limit } = c.req.valid('query'); // Fully typed! return c.json({ page, limit }); });
Middleware in chain:
const app = new Hono() .use('', logger()) .use('/api/', cors()) .get('/api/public', (c) => c.json({ public: true })) .use('/api/admin/*', authMiddleware) .get('/api/admin/users', (c) => c.json({ users: [] }));
</route_chaining>
Factory Pattern — Context Typing
Use createFactory<Env>() to type context variables across middleware and routes.
<factory_pattern>
import { createFactory } from 'hono/factory'; import type { Database } from 'bun:sqlite';
type Env = { Variables: { user: { id: string; role: 'admin' | 'user' }; requestId: string; db: Database; }; };
const factory = createFactory<Env>();
// Typed middleware const authMiddleware = factory.createMiddleware(async (c, next) => { const token = c.req.header('authorization')?.replace('Bearer ', ''); if (!token) throw new HTTPException(401, { message: 'Unauthorized' });
const user = await verifyToken(token); c.set('user', user); // Type-checked! await next(); });
// Typed handlers const getProfile = factory.createHandlers((c) => { const user = c.get('user'); // Typed: { id: string; role: 'admin' | 'user' } return c.json({ user }); });
// Assemble app const app = factory.createApp() .use('', dbMiddleware) .use('/api/', authMiddleware) .get('/api/profile', ...getProfile);
export type AppType = typeof app;
Multi-module structure:
// routes/users.ts export const usersRoute = factory.createApp() .get('/', (c) => c.json({ users: [] })) .post('/', zValidator('json', CreateUserSchema), async (c) => { const data = c.req.valid('json'); return c.json({ created: true }, 201); });
// index.ts const app = factory.createApp() .use('*', dbMiddleware) .route('/users', usersRoute) .route('/posts', postsRoute);
See factory-pattern.md for advanced patterns.
</factory_pattern>
Error Handling
<error_handling>
import { HTTPException } from 'hono/http-exception';
// Throw typed errors app.get('/users/:id', async (c) => { const user = await findUser(c.req.param('id')); if (!user) { throw new HTTPException(404, { message: 'User not found' }); } return c.json({ user }); });
// Custom error classes
class NotFoundError extends HTTPException {
constructor(resource: string) {
super(404, { message: ${resource} not found });
}
}
class UnauthorizedError extends HTTPException { constructor(message = 'Unauthorized') { super(401, { message }); } }
// Centralized handler app.onError((err, c) => { if (err instanceof HTTPException) { return c.json({ error: err.message }, err.status); } if (err instanceof ZodError) { return c.json({ error: 'Validation failed', issues: err.issues.map(i => ({ path: i.path.join('.'), message: i.message })) }, 400); } const isDev = Bun.env.NODE_ENV !== 'production'; return c.json({ error: isDev ? err.message : 'Internal server error' }, 500); });
app.notFound((c) => c.json({ error: 'Not found', path: c.req.path }, 404));
See error-handling.md for patterns.
</error_handling>
Zod OpenAPI
<zod_openapi>
import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; import { swaggerUI } from '@hono/swagger-ui';
const UserSchema = z.object({ id: z.string().uuid(), email: z.string().email(), name: z.string().min(1).max(100), }).openapi('User');
const route = createRoute({ method: 'get', path: '/users/{id}', request: { params: z.object({ id: z.string().uuid() }), }, responses: { 200: { content: { 'application/json': { schema: UserSchema } }, description: 'User found', }, 404: { content: { 'application/json': { schema: z.object({ error: z.string() }) } }, description: 'User not found', }, }, tags: ['Users'], summary: 'Get user by ID', });
const app = new OpenAPIHono();
app.openapi(route, (c) => { const { id } = c.req.valid('param'); // Typed! const user = db.query('SELECT * FROM users WHERE id = ?').get(id); if (!user) return c.json({ error: 'User not found' }, 404); return c.json(user, 200); });
// Swagger UI app.get('/docs', swaggerUI({ url: '/openapi.json' })); app.doc('/openapi.json', { openapi: '3.1.0', info: { title: 'API', version: '1.0.0' }, });
See zod-openapi.md for complete patterns.
</zod_openapi>
RPC Client — End-to-End Types
<rpc_client>
// Server const app = new Hono() .get('/posts', (c) => c.json({ posts: [] })) .get('/posts/:id', (c) => c.json({ id: c.req.param('id') })) .post('/posts', zValidator('json', CreatePostSchema), async (c) => { const data = c.req.valid('json'); return c.json({ id: '123', ...data }, 201); });
export type AppType = typeof app;
// Client import { hc } from 'hono/client'; import type { AppType } from './server';
const client = hc<AppType>('http://localhost:3000');
// GET request const res = await client.posts.$get(); const data = await res.json(); // Typed: { posts: any[] }
// GET with params const res2 = await client.posts[':id'].$get({ param: { id: '123' } });
// POST request const res3 = await client.posts.$post({ json: { title: 'Hello', content: 'World' } });
// With headers const res4 = await client.posts.$get({}, { headers: { Authorization: 'Bearer token' } });
</rpc_client>
Testing with testClient
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'; import { testClient } from 'hono/testing'; import { Database } from 'bun:sqlite'; import app from './server';
describe('API Tests', () => { let db: Database;
beforeEach(() => { db = new Database(':memory:'); db.run('CREATE TABLE posts (id TEXT PRIMARY KEY, title TEXT, content TEXT)'); });
afterEach(() => { db.close(); });
const client = testClient(app);
test('GET /posts returns posts', async () => { const res = await client.posts.$get(); expect(res.status).toBe(200); const data = await res.json(); expect(data).toHaveProperty('posts'); });
test('POST /posts creates post', async () => { const res = await client.posts.$post({ json: { title: 'Test', content: 'Content' } }); expect(res.status).toBe(201); const data = await res.json(); expect(data).toMatchObject({ title: 'Test' }); });
test('Protected route requires auth', async () => { const res = await client.api.profile.$get(); expect(res.status).toBe(401); });
test('Protected route accepts valid token', async () => { const res = await client.api.profile.$get({}, { headers: { Authorization: 'Bearer valid-token' } }); expect(res.status).toBe(200); }); });
See testing-patterns.md for complete patterns.
Middleware Patterns
// Logging import { logger } from 'hono/logger'; app.use('*', logger());
// CORS import { cors } from 'hono/cors'; app.use('/api/*', cors({ origin: ['http://localhost:3000'], credentials: true, }));
// Rate limiting
const rateLimiter = factory.createMiddleware(async (c, next) => {
const ip = c.req.header('x-forwarded-for') || 'unknown';
const key = rate:${ip};
const count = await cache.incr(key);
if (count === 1) await cache.expire(key, 60); if (count > 100) { throw new HTTPException(429, { message: 'Rate limit exceeded' }); }
await next(); });
// Request ID const requestId = factory.createMiddleware(async (c, next) => { c.set('requestId', crypto.randomUUID()); await next(); c.res.headers.set('x-request-id', c.get('requestId')); });
Rules
ALWAYS:
-
Chain routes for type inference → .get().post().put()
-
Export type AppType = typeof app for RPC client
-
Use createFactory<Env>() for typed context variables
-
Validate with Zod schemas via zValidator
-
Handle errors with HTTPException and centralized onError
-
Test with testClient for type safety
NEVER:
-
Break method chain with variable assignment between routes
-
Use any types — let Hono infer or define explicitly
-
Use JSON.parse(await c.req.text()) — use c.req.json() or Zod validator
-
Skip request validation on user input
-
Expose stack traces in production
When Type Errors Occur:
-
Check route chaining not broken
-
Verify export type AppType matches actual app
-
Ensure middleware uses createFactory for context types
-
Check client using correct param , query , or json keys
References
Examples:
-
typed-routes.md — Complete route chaining examples
-
testing-patterns.md — Testing with testClient
References:
-
factory-pattern.md — Context typing with createFactory
-
zod-openapi.md — OpenAPI integration patterns
-
error-handling.md — HTTPException and error patterns
-
middleware.md — Auth, logging, CORS patterns
External:
-
Hono: https://hono.dev
-
@hono/zod-openapi: https://github.com/honojs/middleware/tree/main/packages/zod-openapi