home / skills / outfitter-dev / agents / hono-dev

This skill helps you build type-safe Hono APIs with end-to-end typing, OpenAPI, and RPC client integration.

npx playbooks add skill outfitter-dev/agents --skill hono-dev

Review the files below or copy the command above to add this skill to your agents.

Files (7)
SKILL.md
11.2 KB
---
name: hono-dev
description: This skill should be used when building APIs with Hono, using hc client, implementing OpenAPI, or when "Hono", "RPC", or "type-safe API" are mentioned.
metadata:
  version: "1.0.0"
---

# 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>

```typescript
// ✅ 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:**

```typescript
const app = new Hono();
app.get('/users', handler1);  // Types LOST!
app.post('/users', handler2);
```

**Path parameters** — typed automatically:

```typescript
.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:

```typescript
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:**

```typescript
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>

```typescript
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:**

```typescript
// 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](references/factory-pattern.md) for advanced patterns.

</factory_pattern>

## Error Handling

<error_handling>

```typescript
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](references/error-handling.md) for patterns.

</error_handling>

## Zod OpenAPI

<zod_openapi>

```typescript
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](references/zod-openapi.md) for complete patterns.

</zod_openapi>

## RPC Client — End-to-End Types

<rpc_client>

```typescript
// 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

<testing>

```typescript
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](examples/testing-patterns.md) for complete patterns.

</testing>

## Middleware Patterns

<middleware>

```typescript
// 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'));
});
```

</middleware>

<rules>

## 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

</rules>

<references>

## References

**Examples:**
- [typed-routes.md](examples/typed-routes.md) — Complete route chaining examples
- [testing-patterns.md](examples/testing-patterns.md) — Testing with testClient

**References:**
- [factory-pattern.md](references/factory-pattern.md) — Context typing with createFactory
- [zod-openapi.md](references/zod-openapi.md) — OpenAPI integration patterns
- [error-handling.md](references/error-handling.md) — HTTPException and error patterns
- [middleware.md](references/middleware.md) — Auth, logging, CORS patterns

**External:**
- Hono: <https://hono.dev>
- @hono/zod-openapi: <https://github.com/honojs/middleware/tree/main/packages/zod-openapi>

</references>

Overview

This skill helps you build type-safe Hono APIs, generate OpenAPI docs with Zod, and create end-to-end typed RPC clients using hono/client. It bundles patterns for route chaining, factory-based context typing, centralized error handling, middleware, and testing with testClient. Use it to keep runtime behavior and TypeScript types aligned across server, docs, and client code.

How this skill works

It enforces critical patterns like chaining route definitions so TypeScript preserves full route inference, and exporting AppType = typeof app to derive client types. Use createFactory<Env>() to propagate typed context values through middleware and handlers. Integrations include zValidator for request validation, @hono/zod-openapi for OpenAPI generation, and testClient/hc for typed testing and RPC clients.

When to use it

  • Building REST or RPC-style APIs with Hono where end-to-end types matter
  • Creating a type-safe client with hono/client from your server AppType
  • Generating OpenAPI documentation from Zod schemas and serving Swagger UI
  • Writing integration tests with testClient that preserve API types
  • Implementing middleware that shares typed context (database, user, requestId)

Best practices

  • Always chain route definitions (app.get(...).post(...).put(...)) to preserve type inference
  • Export type AppType = typeof app so hc<Client> can infer routes and payloads
  • Use createFactory<Env>() for typed middleware state and c.get/c.set usage
  • Validate inputs with Zod via zValidator and surface errors centrally with onError
  • Test routes with testClient to validate behavior and keep typed request/response shapes

Example use cases

  • A microservice exposing typed endpoints and a generated hc client for other services
  • An API that publishes OpenAPI JSON from z.object schemas and serves Swagger UI
  • A multi-module app where each route file is a factory.createApp() and mounted via .route()
  • Integration tests that run against an in-memory DB using testClient to assert responses
  • Middleware that injects a typed DB connection, requestId, and authenticated user into handlers

FAQ

What breaks type inference in Hono?

Assigning app to a variable then calling app.get/post separately breaks the chain; always chain methods directly when defining routes.

How do I get a typed client for my server?

Export `type AppType = typeof app` from the server and pass it to hc<AppType>(baseUrl) to get end-to-end typed requests.

How should I validate queries and bodies?

Use zValidator with Zod schemas so c.req.valid('query'|'json') returns fully typed, validated data and errors are mappable in onError.