assets/error-handler.ts
/**
* Standard Error Handler
*
* Returns JSON errors for all routes.
* API routes must return JSON, never HTML redirects.
*/
import type { Context } from 'hono'
import { HTTPException } from 'hono/http-exception'
export const errorHandler = (err: Error, c: Context) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status)
}
console.error('Unhandled error:', err)
return c.json({ error: 'Internal server error' }, 500)
}
assets/middleware-template.ts
/**
* Auth Middleware Template
*
* Validates Bearer token and sets userId/role in context.
* Customise the token validation logic for your auth system.
*/
import { createMiddleware } from 'hono/factory'
import type { Env } from '../types'
type AuthVariables = {
userId: string
role: string
}
export const requireAuth = createMiddleware<{
Bindings: Env
Variables: AuthVariables
}>(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) {
return c.json({ error: 'Unauthorized' }, 401)
}
// TODO: Validate token (JWT, session lookup, API key check, etc.)
// const decoded = await verifyToken(token, c.env.JWT_SECRET)
// c.set('userId', decoded.sub)
// c.set('role', decoded.role)
await next()
})
assets/route-template.ts
/**
* Route Template — [Resource Name]
*
* Copy this file to src/routes/[resource].ts and customise.
* Includes: list, get, create, update, delete with Zod validation.
*/
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import type { Env } from '../types'
const app = new Hono<{ Bindings: Env }>()
const createSchema = z.object({
name: z.string().min(1).max(100),
// Add fields here
})
const updateSchema = createSchema.partial()
// GET /api/[resource]
app.get('/', async (c) => {
const db = c.env.DB
const { results } = await db.prepare('SELECT * FROM [table] ORDER BY created_at DESC').all()
return c.json({ items: results })
})
// GET /api/[resource]/:id
app.get('/:id', async (c) => {
const id = c.req.param('id')
const item = await c.env.DB.prepare('SELECT * FROM [table] WHERE id = ?').bind(id).first()
if (!item) return c.json({ error: 'Not found' }, 404)
return c.json({ item })
})
// POST /api/[resource]
app.post('/', zValidator('json', createSchema), async (c) => {
const body = c.req.valid('json')
const id = crypto.randomUUID()
await c.env.DB.prepare('INSERT INTO [table] (id, name) VALUES (?, ?)').bind(id, body.name).run()
return c.json({ item: { id, ...body } }, 201)
})
// PUT /api/[resource]/:id
app.put('/:id', zValidator('json', updateSchema), async (c) => {
const id = c.req.param('id')
const body = c.req.valid('json')
// ... update logic
return c.json({ item: { id, ...body } })
})
// DELETE /api/[resource]/:id
app.delete('/:id', async (c) => {
const id = c.req.param('id')
await c.env.DB.prepare('DELETE FROM [table] WHERE id = ?').bind(id).run()
return c.body(null, 204)
})
export default app
references/endpoint-docs-template.md
# API Endpoints Documentation Template
Use this format when generating API_ENDPOINTS.md for a project.
## Template
```markdown
# API Endpoints
Base URL: `https://[project].workers.dev` (production) | `http://localhost:5173` (dev)
## Authentication
[Describe auth method: Bearer token, session cookie, API key, none]
## Endpoints
### [Resource Name]
#### GET /api/[resource]
List all [resources].
- **Auth**: [Required/None]
- **Query params**:
- `page` (number, default: 1)
- `limit` (number, default: 20, max: 100)
- `search` (string, optional)
- **Response 200**:
```json
{
"[resources]": [...],
"total": 42,
"page": 1,
"limit": 20
}
```
#### GET /api/[resource]/:id
Get a single [resource] by ID.
- **Auth**: [Required/None]
- **Response 200**: `{ "[resource]": { ... } }`
- **Response 404**: `{ "error": "Not found" }`
#### POST /api/[resource]
Create a new [resource].
- **Auth**: Required
- **Body**:
```json
{
"name": "string (required)",
"email": "string (required, email format)"
}
```
- **Response 201**: `{ "[resource]": { ... } }`
- **Response 400**: `{ "error": "Validation failed", "details": { ... } }`
#### PUT /api/[resource]/:id
Update an existing [resource].
- **Auth**: Required
- **Body**: Same as POST (all fields optional)
- **Response 200**: `{ "[resource]": { ... } }`
- **Response 404**: `{ "error": "Not found" }`
#### DELETE /api/[resource]/:id
Delete a [resource].
- **Auth**: Required
- **Response 204**: (no body)
- **Response 404**: `{ "error": "Not found" }`
## Error Format
All errors return JSON:
```json
{
"error": "Human-readable error message",
"details": {} // optional, present for validation errors
}
```
| Status | Meaning |
|--------|---------|
| 400 | Bad request / validation error |
| 401 | Not authenticated |
| 403 | Not authorised |
| 404 | Resource not found |
| 500 | Internal server error |
```
## Guidelines
- Document every endpoint, including auth requirements
- Show example request bodies with field types and constraints
- Show example response shapes (JSON)
- Include all possible error responses
- List query parameters with defaults and limits
- Keep descriptions to one line per endpoint
references/hono-patterns.md
# Hono Patterns
Advanced patterns for Hono on Cloudflare Workers. Load this when building complex APIs or troubleshooting route issues.
## Route Priority
Hono matches routes in registration order. Place specific routes before generic ones:
```typescript
// Correct order
app.get('/api/users/me', getMeHandler) // specific first
app.get('/api/users/:id', getUserHandler) // param route second
app.get('/api/users', listUsersHandler) // list last
```
## Middleware Chains
Middleware runs in order of `app.use()` registration:
```typescript
app.use('*', logger()) // all routes
app.use('/api/*', cors()) // all API routes
app.use('/api/admin/*', requireAuth) // admin routes only
```
### Per-Route Middleware
```typescript
app.get('/api/secret', requireAuth, async (c) => {
// runs after auth middleware
})
```
### Typed Context with Variables
```typescript
// Set in middleware
export const authMiddleware = createMiddleware<{
Bindings: Env
Variables: { userId: string; role: string }
}>(async (c, next) => {
c.set('userId', decoded.sub)
c.set('role', decoded.role)
await next()
})
// Read in handler
app.get('/api/me', authMiddleware, (c) => {
const userId = c.get('userId') // typed string
})
```
## Request Handling
### Path Parameters
```typescript
app.get('/api/posts/:id', (c) => {
const id = c.req.param('id') // string
})
// Multiple params
app.get('/api/orgs/:orgId/users/:userId', (c) => {
const { orgId, userId } = c.req.param()
})
```
### Query Parameters
```typescript
app.get('/api/users', (c) => {
const page = parseInt(c.req.query('page') || '1')
const limit = parseInt(c.req.query('limit') || '20')
const search = c.req.query('search')
})
```
### Headers
```typescript
const token = c.req.header('Authorization')?.replace('Bearer ', '')
const contentType = c.req.header('Content-Type')
```
## Response Patterns
### JSON (most common)
```typescript
return c.json({ users }, 200)
return c.json({ error: 'Not found' }, 404)
return c.json({ user }, 201) // created
```
### Empty responses
```typescript
return c.body(null, 204) // no content (DELETE success)
return new Response(null, { status: 204 })
```
### Redirects
```typescript
return c.redirect('/login', 302)
```
### Streaming
```typescript
return c.stream(async (stream) => {
await stream.write('chunk 1')
await stream.write('chunk 2')
})
```
## Error Handling
### Global error handler
```typescript
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)
})
```
### HTTPException
```typescript
import { HTTPException } from 'hono/http-exception'
throw new HTTPException(403, { message: 'Forbidden' })
```
### Not Found handler
```typescript
app.notFound((c) => c.json({ error: 'Not found' }, 404))
```
## Zod Validation Patterns
### Body validation
```typescript
const schema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
role: z.enum(['admin', 'editor', 'viewer']).optional(),
})
app.post('/', zValidator('json', schema), async (c) => {
const body = c.req.valid('json')
})
```
### Query validation
```typescript
const querySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(),
})
app.get('/', zValidator('query', querySchema), async (c) => {
const { page, limit, search } = c.req.valid('query')
})
```
### Custom error response
```typescript
app.post('/',
zValidator('json', schema, (result, c) => {
if (!result.success) {
return c.json({ error: 'Validation failed', details: result.error.flatten() }, 400)
}
}),
handler,
)
```
## RPC (Remote Procedure Call)
End-to-end type safety between Worker and client without code generation:
```typescript
// Worker: chain routes for type inference
const routes = app
.get('/api/users', async (c) => c.json({ users: [] }))
.post('/api/users', zValidator('json', schema), async (c) => c.json({ user: {} }, 201))
export type AppType = typeof routes
// Client:
import { hc } from 'hono/client'
import type { AppType } from './worker'
const client = hc<AppType>('https://api.example.com')
const res = await client.api.users.$get()
const data = await res.json() // typed: { users: User[] }
```
**Key**: The route chain must be assigned to a variable for type inference to work. Don't use `app.route()` for RPC — mount routes directly on the app.
SKILL.md
---
name: hono-api-scaffolder
description: "Scaffold Hono API routes for Cloudflare Workers. Produces route files, middleware, typed bindings, Zod validation, error handling, and API_ENDPOINTS.md documentation. Use after a project is set up with cloudflare-worker-builder or vite-flare-starter, when you need to add API routes, create endpoints, or generate API documentation."
compatibility: claude-code-only
---
# Hono API Scaffolder
Add structured API routes to an existing Cloudflare Workers project. This skill runs AFTER the project shell exists (via cloudflare-worker-builder or vite-flare-starter) and produces route files, middleware, and endpoint documentation.
## Workflow
### Step 1: Gather Endpoints
Determine what the API needs. Either ask the user or infer from the project description. Group endpoints by resource:
```
Users: GET /api/users, GET /api/users/:id, POST /api/users, PUT /api/users/:id, DELETE /api/users/:id
Posts: GET /api/posts, GET /api/posts/:id, POST /api/posts, PUT /api/posts/:id
Auth: POST /api/auth/login, POST /api/auth/logout, GET /api/auth/me
```
### Step 2: Create Route Files
One file per resource group. Use the template from [assets/route-template.ts](assets/route-template.ts):
```typescript
// src/routes/users.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import type { Env } from '../types'
const app = new Hono<{ Bindings: Env }>()
// GET /api/users
app.get('/', async (c) => {
const db = c.env.DB
const { results } = await db.prepare('SELECT * FROM users').all()
return c.json({ users: results })
})
// GET /api/users/:id
app.get('/:id', async (c) => {
const id = c.req.param('id')
const user = await db.prepare('SELECT * FROM users WHERE id = ?').bind(id).first()
if (!user) return c.json({ error: 'Not found' }, 404)
return c.json({ user })
})
// POST /api/users
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
app.post('/', zValidator('json', createUserSchema), async (c) => {
const body = c.req.valid('json')
// ... insert logic
return c.json({ user }, 201)
})
export default app
```
### Step 3: Add Middleware
Based on project needs, add from [assets/middleware-template.ts](assets/middleware-template.ts):
**Auth middleware** — protect routes requiring authentication:
```typescript
import { createMiddleware } from 'hono/factory'
import type { Env } from '../types'
export const requireAuth = createMiddleware<{ Bindings: Env }>(async (c, next) => {
const token = c.req.header('Authorization')?.replace('Bearer ', '')
if (!token) return c.json({ error: 'Unauthorized' }, 401)
// Validate token...
await next()
})
```
**CORS** — use Hono's built-in:
```typescript
import { cors } from 'hono/cors'
app.use('/api/*', cors({ origin: ['https://example.com'] }))
```
### Step 4: Wire Routes
Mount all route groups in the main entry point:
```typescript
// src/index.ts
import { Hono } from 'hono'
import type { Env } from './types'
import users from './routes/users'
import posts from './routes/posts'
import auth from './routes/auth'
import { errorHandler } from './middleware/error-handler'
const app = new Hono<{ Bindings: Env }>()
// Global error handler
app.onError(errorHandler)
// Mount routes
app.route('/api/users', users)
app.route('/api/posts', posts)
app.route('/api/auth', auth)
// Health check
app.get('/api/health', (c) => c.json({ status: 'ok' }))
export default app
```
### Step 5: Create Types
```typescript
// src/types.ts
export interface Env {
DB: D1Database
KV: KVNamespace // if needed
R2: R2Bucket // if needed
API_SECRET: string // secrets
}
```
### Step 6: Generate API_ENDPOINTS.md
Document all endpoints. See [references/endpoint-docs-template.md](references/endpoint-docs-template.md) for the format:
```markdown
## POST /api/users
Create a new user.
- **Auth**: Required (Bearer token)
- **Body**: `{ name: string, email: string }`
- **Response 201**: `{ user: User }`
- **Response 400**: `{ error: string, details: ZodError }`
```
## Key Patterns
### Zod Validation
Always validate request bodies with `@hono/zod-validator`:
```typescript
import { zValidator } from '@hono/zod-validator'
app.post('/', zValidator('json', schema), async (c) => {
const body = c.req.valid('json') // fully typed
})
```
Install: `pnpm add @hono/zod-validator zod`
### Error Handling
Use the standard error handler from [assets/error-handler.ts](assets/error-handler.ts):
```typescript
export const errorHandler = (err: Error, c: Context) => {
console.error(err)
return c.json({ error: err.message }, 500)
}
```
**API routes must return JSON errors, not redirects.** `fetch()` follows redirects silently, then the client tries to parse HTML as JSON.
### RPC Type Safety
For end-to-end type safety between Worker and client:
```typescript
// Worker: export the app type
export type AppType = typeof app
// Client: use hc (Hono Client)
import { hc } from 'hono/client'
import type { AppType } from '../worker/src/index'
const client = hc<AppType>('https://api.example.com')
const res = await client.api.users.$get() // fully typed
```
### Route Groups vs Single File
| Project size | Structure |
|-------------|-----------|
| < 10 endpoints | Single `index.ts` with all routes |
| 10-30 endpoints | Route files per resource (`routes/users.ts`) |
| 30+ endpoints | Route files + shared middleware + typed context |
## Reference Files
| When | Read |
|------|------|
| Hono patterns, middleware, RPC | [references/hono-patterns.md](references/hono-patterns.md) |
| API_ENDPOINTS.md format | [references/endpoint-docs-template.md](references/endpoint-docs-template.md) |
## Assets
| File | Purpose |
|------|---------|
| [assets/route-template.ts](assets/route-template.ts) | Starter route file with CRUD + Zod |
| [assets/middleware-template.ts](assets/middleware-template.ts) | Auth middleware template |
| [assets/error-handler.ts](assets/error-handler.ts) | Standard JSON error handler |