← All posts
·19 min read

Claude Code with Hono: Edge-First API Development

Claude CodeHonoBackendEdge
Claude Code with Hono: Edge-First API Development

Why Hono without CLAUDE.md leaks runtime assumptions

Hono's selling point is that the same application code runs on Cloudflare Workers, Bun, Deno, and Node without rewriting routes or middleware. That promise is real, but it has one sharp edge: the deployment export signature is different for every runtime, and Claude Code does not know which one you are targeting without being told.

Without a CLAUDE.md, Claude makes an assumption. It usually assumes you are on Node because Node is the most common runtime in its training data. You get app.listen(3000) in a project destined for Workers, or Express-style (req, res, next) middleware in a codebase that uses Hono's Context pattern, or await c.req.body() instead of await c.req.json() because the method names look close enough that Claude guesses. None of these produce a runtime error on the first line. They fail at the boundary between your application and the platform, which is harder to diagnose.

The second class of issues is Hono-specific. Hono's context object c is not a Node HTTP response wrapper. It does not have res.send() or res.json(). It has c.text(), c.json(), c.html(), and c.redirect(). These are different method names doing similar things, and Claude, drawing on Express experience, reaches for the wrong names under time pressure. The same happens with c.env for Cloudflare Workers environment bindings: Claude sometimes invents process.env access patterns that work on Node but silently return undefined on Workers.

This guide covers the CLAUDE.md configuration that anchors Claude Code to Hono 4's actual model: the Context object as the single interface for requests and responses, runtime-explicit export signatures, type-safe Bindings generics for Workers, zValidator middleware chains, and RPC mode for end-to-end type safety. If you are new to Claude Code, the Claude Code setup guide covers installation and project layout. For the Cloudflare Workers deployment context that Hono often targets, Claude Code with Cloudflare Workers is a useful companion.

The Hono CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every session. For a Hono project it needs to declare: Hono version and runtime target, the project structure, the Context object as the only request/response interface, middleware patterns, validator configuration, the runtime-specific export signature, and the hard rules that block the patterns Claude generates most often without guidance.

# Hono API rules

## Stack
- Hono 4.x, TypeScript 5.x strict
- Runtime: Cloudflare Workers (change to Bun / Deno / Node if different)
- Zod 3.x + @hono/zod-validator for request validation
- Vitest for unit tests

## Project structure
- src/index.ts        , app entrypoint and export
- src/routes/         , one file per route group (users.ts, orders.ts)
- src/middleware/     , custom middleware (auth.ts, ratelimit.ts)
- src/types.ts        , Env Bindings interface and shared types
- src/lib/            , shared utilities (db.ts, email.ts)
- test/               , Vitest unit tests mirroring src/ structure

## The Context object (c) is the ONLY interface
- NEVER write Express-style handlers: (req, res) => { res.json({}) }
- ALWAYS write Hono handlers: (c) => c.json({})
- Response methods on c: c.json(), c.text(), c.html(), c.redirect(), c.body()
- Request access: c.req.param(), c.req.query(), c.req.header(), await c.req.json()
- ALWAYS await c.req.json() and c.req.formData() - they are async
- c.req.valid('json') returns the validated body after zValidator (not c.req.json())
- Variables set by middleware: c.set('key', value) - read with c.get('key')

## App and router setup
- Create the root app with type parameter: const app = new Hono<{ Bindings: Env }>()
- Create sub-routers the same way: const users = new Hono<{ Bindings: Env }>()
- Mount sub-routers with app.route('/users', users)
- NEVER use app.use() for route-level middleware - use app.use('/path/*', middleware)
- app.notFound(c => c.json({ error: 'Not found' }, 404))
- app.onError((err, c) => c.json({ error: err.message }, 500))

## Middleware patterns
- Import from 'hono/logger', 'hono/cors', 'hono/bearer-auth', etc.
- Global middleware: app.use('*', logger())
- Path-scoped middleware: app.use('/admin/*', bearerAuth({ token: c.env.ADMIN_TOKEN }))
- Custom middleware signature: async (c, next) => { await next() }
- ALWAYS call await next() in custom middleware unless intentionally short-circuiting
- Return nothing from middleware - responses go through c, not return values

## Type-safe routing with Bindings
- Define Env interface in src/types.ts matching wrangler.toml [vars] and bindings
- Example: interface Env { DB: D1Database; KV: KVNamespace; API_KEY: string }
- Pass Env to Hono generic: new Hono<{ Bindings: Env }>()
- Access env vars in handlers: c.env.API_KEY (not process.env.API_KEY)
- For Workers KV: c.env.KV.get('key'), c.env.KV.put('key', 'value')
- For Workers D1: c.env.DB.prepare('SELECT ...').bind(...).first()

## Validator middleware (zValidator)
- Import: import { zValidator } from '@hono/zod-validator'
- Import: import { z } from 'zod'
- Inline: app.post('/', zValidator('json', z.object({ name: z.string() })), (c) => {
    const { name } = c.req.valid('json')  // typed, validated
    return c.json({ name })
  })
- ALWAYS use c.req.valid('json') after zValidator, NEVER c.req.json() again
- Validate query params: zValidator('query', z.object({ page: z.coerce.number() }))
- Validate path params: zValidator('param', z.object({ id: z.string().uuid() }))
- Validation errors automatically return 400 with Zod error details

## Runtime export signatures - PICK ONE, never mix

### Cloudflare Workers
export default app

### Bun
export default {
  port: 3000,
  fetch: app.fetch,
}

### Deno
Deno.serve(app.fetch)

### Node (requires @hono/node-server)
import { serve } from '@hono/node-server'
serve({ fetch: app.fetch, port: 3000 })

## RPC mode (optional, for full-stack TypeScript)
- Export the app type: export type AppType = typeof app
- Client: import { hc } from 'hono/client'
- Client: const client = hc<AppType>('/api')
- Client: const res = await client.users.$get()
- RPC requires named route exports (chained .get() .post() must stay on the same object)
- NEVER destructure app methods before chaining - RPC type inference breaks

## Hard rules
- NEVER write (req, res) handler signatures - this is Express, not Hono
- NEVER use process.env in Workers code - use c.env
- NEVER use c.body to read request body - use await c.req.json()
- NEVER forget await on c.req.json() or c.req.formData()
- NEVER place app.listen() - Hono does not have a listen method
- ALWAYS export using the runtime-correct signature above
- ALWAYS use c.req.valid() after a validator middleware, not c.req.json()
- Custom middleware MUST call await next() unless intentionally blocking

Four rules here prevent the majority of errors Claude generates without them.

The Context object rule is the most impactful. Claude is trained on more Express code than Hono code. Under pressure it reaches for (req, res) signatures and res.json() calls. These produce a TypeScript type error in a strict project, but they are invisible if you paste Claude's output into a loosely-typed file. The explicit prohibition in CLAUDE.md forces Claude to use c.json() and c.text() from the first attempt.

The await discipline rule catches the single most common Hono bug. c.req.json() returns a Promise. Calling it without await gives you a Promise object as your parsed body, not your actual JSON. The error is silent in JavaScript and a type error only if your TypeScript is strict enough. Declaring it in CLAUDE.md means Claude adds the await consistently.

The runtime export rule removes the assumption problem. Once CLAUDE.md says which runtime you are on, Claude generates the correct export signature every time without prompting.

The process.env prohibition matters specifically for Workers. process.env is a Node global. Workers do not have it. Environment variables and bindings come through c.env, populated from wrangler.toml. Claude generates process.env.DATABASE_URL when the correct Workers pattern is c.env.DATABASE_URL. The rule eliminates that class of silent runtime failure.

Project structure and app setup

Hono apps grow from a single file into a routed, middleware-layered structure quickly. The project layout that scales well has one file per route group, a shared types file for the Env interface, and a middleware directory that separates concerns without coupling handlers to cross-cutting logic.

A starter src/index.ts for a Workers deployment:

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import type { Env } from './types'
import { usersRoute } from './routes/users'
import { ordersRoute } from './routes/orders'

const app = new Hono<{ Bindings: Env }>()

// Global middleware
app.use('*', logger())
app.use('*', cors({ origin: ['https://app.example.com'] }))

// Route groups
app.route('/users', usersRoute)
app.route('/orders', ordersRoute)

// Health check
app.get('/health', (c) => c.json({ status: 'ok' }))

// Error handlers
app.notFound((c) => c.json({ error: 'Not found' }, 404))
app.onError((err, c) => {
  console.error(err)
  return c.json({ error: 'Internal server error' }, 500)
})

export default app

The src/types.ts file is the anchor for Workers bindings:

export interface Env {
  // KV namespaces
  CACHE: KVNamespace
  // D1 databases
  DB: D1Database
  // Secret / plain text vars from wrangler.toml
  API_KEY: string
  WEBHOOK_SECRET: string
}

A route file in src/routes/users.ts:

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import type { Env } from '../types'

export const usersRoute = new Hono<{ Bindings: Env }>()

const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
})

usersRoute.get('/', async (c) => {
  const users = await c.env.DB
    .prepare('SELECT id, name, email FROM users ORDER BY created_at DESC')
    .all()
  return c.json(users.results)
})

usersRoute.post(
  '/',
  zValidator('json', createUserSchema),
  async (c) => {
    const { name, email } = c.req.valid('json')
    const result = await c.env.DB
      .prepare('INSERT INTO users (name, email) VALUES (?1, ?2) RETURNING id')
      .bind(name, email)
      .first<{ id: string }>()
    return c.json({ id: result?.id, name, email }, 201)
  }
)

usersRoute.get('/:id', async (c) => {
  const id = c.req.param('id')
  const user = await c.env.DB
    .prepare('SELECT id, name, email FROM users WHERE id = ?1')
    .bind(id)
    .first()
  if (!user) return c.json({ error: 'User not found' }, 404)
  return c.json(user)
})

Add this pattern to your CLAUDE.md so Claude generates route files with the correct type parameter, zValidator import, and c.req.valid('json') access:

## Route file pattern
- Every route file: import { Hono } from 'hono', import type { Env } from '../types'
- Instantiate with: new Hono<{ Bindings: Env }>()
- Export as named export: export const usersRoute = new Hono<...>()
- Mount in index.ts with: app.route('/users', usersRoute)
- Zod schemas at the top of the file, above the route handlers
- Always use c.req.valid('json') after zValidator - never call c.req.json() again in the same handler

Middleware patterns

Hono ships built-in middleware for the common cases. The middleware chain uses app.use() with path matching, not the router-level use() that Express developers expect.

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { bearerAuth } from 'hono/bearer-auth'
import { rateLimiter } from 'hono-rate-limiter'
import type { Env } from './types'

const app = new Hono<{ Bindings: Env }>()

// Global: runs on every request
app.use('*', logger())

// Path-scoped: runs only on /api/*
app.use('/api/*', cors({
  origin: (origin) => origin.endsWith('.example.com') ? origin : null,
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
}))

// Auth gate: runs only on /admin/*
app.use('/admin/*', async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (token !== c.env.ADMIN_TOKEN) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  await next()
})

Custom middleware for injecting a user into context:

// src/middleware/auth.ts
import { createMiddleware } from 'hono/factory'
import type { Env } from '../types'

type Variables = {
  userId: string
  userRole: 'admin' | 'user'
}

export const authMiddleware = createMiddleware<{
  Bindings: Env
  Variables: Variables
}>(async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token) return c.json({ error: 'Missing token' }, 401)

  // Verify token against KV store
  const userId = await c.env.CACHE.get(`token:${token}`)
  if (!userId) return c.json({ error: 'Invalid token' }, 401)

  c.set('userId', userId)
  c.set('userRole', 'user')
  await next()
})

Using variables in subsequent handlers:

// src/index.ts
const app = new Hono<{ Bindings: Env; Variables: { userId: string } }>()

app.use('/protected/*', authMiddleware)

app.get('/protected/profile', (c) => {
  const userId = c.get('userId')
  return c.json({ userId })
})

Add the Variables pattern to CLAUDE.md so Claude generates the correct generic when you ask for user-passing middleware:

## Middleware Variables pattern
- For middleware that sets values consumed by handlers, declare Variables in the generic
- new Hono<{ Bindings: Env; Variables: { userId: string; userRole: string } }>()
- Set in middleware: c.set('userId', id)
- Read in handler: const userId = c.get('userId')
- createMiddleware from 'hono/factory' gives a typed middleware factory
- NEVER use c.req.headers directly for user identity in handlers - pass through c.set()

Type-safe routes with Hono generics

Hono's type system goes further than most frameworks. When you pass a generic to new Hono(), TypeScript tracks the Env shape through every handler. When you define routes with inline types, Hono's RPC mode can infer the full request and response shape on the client.

The full generic signature:

import { Hono } from 'hono'

type Env = {
  Bindings: {
    DB: D1Database
    API_KEY: string
  }
  Variables: {
    userId: string
  }
}

const app = new Hono<Env>()

For typed route definitions that RPC mode can consume:

// This pattern is required for hc<AppType> to infer response shapes
const routes = app
  .get('/users', (c) => {
    return c.json({ users: [] as Array<{ id: string; name: string }> })
  })
  .post('/users', zValidator('json', createUserSchema), (c) => {
    const body = c.req.valid('json')
    return c.json({ id: 'new-id', ...body }, 201)
  })

export type AppType = typeof routes
export default app

Client-side RPC:

// In your frontend (Next.js, Remix, plain browser)
import { hc } from 'hono/client'
import type { AppType } from '../api/src/index'

const client = hc<AppType>('http://localhost:8787')

// Fully typed: response is inferred from the route definition
const res = await client.users.$get()
const data = await res.json()
// data.users is typed as Array<{ id: string; name: string }>

Add the RPC pattern to CLAUDE.md:

## RPC mode rules
- Chain all route definitions on one object for type inference to work
- Export the chained object type: export type AppType = typeof routes
- Client: import { hc } from 'hono/client'
- Client: const client = hc<AppType>(baseUrl)
- Method calls mirror route paths: client.users.$get(), client.orders.$post()
- The $ prefix maps HTTP methods: $get, $post, $put, $delete, $patch
- NEVER destructure or break the route chain before exporting - type inference breaks
- RPC is optional. If not using it, you do not need to chain routes on one object.

Runtime-specific deployment

Hono's runtime abstraction is nearly complete. The one place it is not is the deployment entry point. Each runtime expects a different export shape. Getting this wrong produces a deployed function that never handles a request.

Cloudflare Workers

# wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[vars]
API_KEY = "dev-key"

[[d1_databases]]
binding = "DB"
database_name = "my-db"
database_id = "your-database-id"

[[kv_namespaces]]
binding = "CACHE"
id = "your-kv-id"
// src/index.ts - Workers export
import { Hono } from 'hono'
import type { Env } from './types'

const app = new Hono<{ Bindings: Env }>()

app.get('/', (c) => c.text('Hello Workers'))

export default app
// Workers calls app.fetch(request, env, ctx) automatically

Bun

// src/index.ts - Bun export
import { Hono } from 'hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello Bun'))

export default {
  port: 3000,
  fetch: app.fetch,
}

Bun does not have a bunfig.toml requirement for a basic HTTP server. For production, you typically add TypeScript config and a build step, but the export format is the object with port and fetch.

Deno

// src/index.ts - Deno export
import { Hono } from 'npm:hono'

const app = new Hono()

app.get('/', (c) => c.text('Hello Deno'))

Deno.serve(app.fetch)
// deno.json
{
  "tasks": {
    "dev": "deno run --allow-net --allow-env src/index.ts",
    "start": "deno run --allow-net --allow-env src/index.ts"
  },
  "imports": {
    "hono": "npm:hono@4"
  }
}

Node

npm install @hono/node-server
// src/index.ts - Node export
import { Hono } from 'hono'
import { serve } from '@hono/node-server'

const app = new Hono()

app.get('/', (c) => c.text('Hello Node'))

serve({
  fetch: app.fetch,
  port: 3000,
})

Add the per-runtime wrangler/config snippets to CLAUDE.md so Claude stops guessing:

## Deployment target (set once, applies to all generated code)
Target runtime: Cloudflare Workers

## Export format
export default app
(No app.listen, no serve(), no Deno.serve)

## Env vars
ALWAYS read from c.env - NEVER from process.env or Deno.env

For the deeper Workers deployment context including wrangler commands, environment management, and D1 migrations, see Claude Code with Cloudflare Workers.

Validator middleware with Zod and Valibot

Request validation is where Hono's middleware composability shines. @hono/zod-validator wraps Zod into a handler-level middleware that runs before your handler function, rejects invalid input with a structured 400 response, and passes the typed validated data through c.req.valid().

Full validation setup:

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()

// JSON body validation
const createOrderSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().int().positive().max(100),
  couponCode: z.string().optional(),
})

app.post(
  '/orders',
  zValidator('json', createOrderSchema, (result, c) => {
    // Custom error handler - override the default 400 shape
    if (!result.success) {
      return c.json({
        error: 'Validation failed',
        issues: result.error.issues.map(i => ({
          field: i.path.join('.'),
          message: i.message,
        })),
      }, 400)
    }
  }),
  async (c) => {
    // c.req.valid('json') is fully typed as the inferred Zod output type
    const { productId, quantity, couponCode } = c.req.valid('json')
    // ... create the order
    return c.json({ orderId: 'new-id', productId, quantity }, 201)
  }
)

// Query parameter validation
const listOrdersSchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().positive().max(100).default(20),
  status: z.enum(['pending', 'confirmed', 'shipped', 'delivered']).optional(),
})

app.get(
  '/orders',
  zValidator('query', listOrdersSchema),
  (c) => {
    const { page, limit, status } = c.req.valid('query')
    return c.json({ page, limit, status, orders: [] })
  }
)

// Path parameter validation
const orderIdSchema = z.object({
  id: z.string().uuid('Order ID must be a valid UUID'),
})

app.get(
  '/orders/:id',
  zValidator('param', orderIdSchema),
  async (c) => {
    const { id } = c.req.valid('param')
    // id is typed as string, UUID-validated
    return c.json({ id })
  }
)

For projects that prefer Valibot over Zod (smaller bundle, especially relevant on edge where every byte matters):

npm install @hono/valibot-validator valibot
import { vValidator } from '@hono/valibot-validator'
import * as v from 'valibot'

const createOrderSchema = v.object({
  productId: v.pipe(v.string(), v.uuid()),
  quantity: v.pipe(v.number(), v.integer(), v.minValue(1)),
})

app.post(
  '/orders',
  vValidator('json', createOrderSchema),
  (c) => {
    const { productId, quantity } = c.req.valid('json')
    return c.json({ productId, quantity }, 201)
  }
)

Add validation rules to CLAUDE.md:

## Validation rules
- Import: import { zValidator } from '@hono/zod-validator'
- Always validate 'json' for POST/PUT/PATCH, 'query' for GET with filters, 'param' for path vars
- After zValidator: use c.req.valid('json') not c.req.json() - it returns the typed, parsed result
- Custom error handler: third arg to zValidator() - use to shape 400 responses consistently
- For edge bundle size sensitivity: consider @hono/valibot-validator + valibot instead of Zod

Error handling and status codes

Hono's error handling composes at two levels: the global app.onError() handler catches anything thrown in a route, and per-route try/catch gives you fine-grained status codes.

import { Hono } from 'hono'
import { HTTPException } from 'hono/http-exception'

const app = new Hono()

// HTTPException gives you typed throws with status codes
app.get('/protected', (c) => {
  const token = c.req.header('Authorization')
  if (!token) {
    throw new HTTPException(401, { message: 'Authentication required' })
  }
  return c.json({ data: 'secret' })
})

// Global error handler catches HTTPException and anything else
app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return c.json({ error: err.message }, err.status)
  }
  // Log unexpected errors before returning
  console.error('Unhandled error:', err)
  return c.json({ error: 'Internal server error' }, 500)
})

// 404 handler for unmatched routes
app.notFound((c) => c.json({ error: `Route ${c.req.url} not found` }, 404))

Custom error classes:

class AppError extends Error {
  constructor(
    public message: string,
    public statusCode: number = 500,
    public code: string = 'INTERNAL_ERROR',
  ) {
    super(message)
    this.name = 'AppError'
  }
}

class NotFoundError extends AppError {
  constructor(resource: string) {
    super(`${resource} not found`, 404, 'NOT_FOUND')
  }
}

class ValidationError extends AppError {
  constructor(message: string) {
    super(message, 400, 'VALIDATION_ERROR')
  }
}

// In handlers
app.get('/users/:id', async (c) => {
  const id = c.req.param('id')
  const user = await getUserById(id)
  if (!user) throw new NotFoundError('User')
  return c.json(user)
})

// Global handler catches them all
app.onError((err, c) => {
  if (err instanceof AppError) {
    return c.json({
      error: err.message,
      code: err.code,
    }, err.statusCode as 400 | 401 | 403 | 404 | 500)
  }
  if (err instanceof HTTPException) {
    return c.json({ error: err.message }, err.status)
  }
  console.error(err)
  return c.json({ error: 'Internal server error' }, 500)
})

Add to CLAUDE.md:

## Error handling rules
- Import HTTPException from 'hono/http-exception' for typed HTTP errors
- throw new HTTPException(404, { message: 'Not found' }) in any handler
- app.onError catches all thrown errors - always handle HTTPException as the first branch
- app.notFound handles routes that do not match any registered handler
- NEVER return a response without a status code on error paths - always pass 4xx or 5xx
- Custom error classes: extend Error, carry statusCode and code fields

Common gotchas

Several Hono patterns catch developers (and Claude) by surprise. These belong in CLAUDE.md to prevent regenerating the same bugs.

Async middleware that forgets next. Claude sometimes generates middleware that reads from the database and returns early on success, forgetting await next(). The handler after the middleware never runs.

// WRONG - handler never called on success
app.use('/api/*', async (c, next) => {
  const user = await getUser(c.req.header('x-user-id') ?? '')
  if (!user) return c.json({ error: 'Forbidden' }, 403)
  c.set('user', user)
  // missing: await next()
})

// CORRECT
app.use('/api/*', async (c, next) => {
  const user = await getUser(c.req.header('x-user-id') ?? '')
  if (!user) return c.json({ error: 'Forbidden' }, 403)
  c.set('user', user)
  await next()
})

Reading body twice. c.req.json() can only be called once on a Workers request. If middleware reads the body and a handler tries to read it again, the second read returns an empty or error result.

// WRONG - body read twice
app.use('/orders/*', async (c, next) => {
  const body = await c.req.json()  // consumed here
  console.log('body:', body)
  await next()
})

app.post('/orders', async (c) => {
  const body = await c.req.json()  // undefined or error
  return c.json(body)
})

// CORRECT - clone the body or use c.set to pass it
app.use('/orders/*', async (c, next) => {
  const body = await c.req.json()
  c.set('body', body)  // pass it through Variables
  await next()
})

Workers vs. Bun port configuration. Workers does not have a port. Bun expects a port in the export object. Claude sometimes adds a port to a Workers export:

// WRONG for Workers
export default {
  port: 8787,   // Workers ignores this, but it is misleading
  fetch: app.fetch,
}

// CORRECT for Workers
export default app

// CORRECT for Bun
export default {
  port: 3000,
  fetch: app.fetch,
}

Missing content-type on raw responses. c.body() sends a raw response body. Without setting the content-type header, clients receive application/octet-stream.

// WRONG
app.get('/data.csv', (c) => c.body(csvData))

// CORRECT
app.get('/data.csv', (c) => {
  c.header('Content-Type', 'text/csv')
  return c.body(csvData)
})

Add to CLAUDE.md:

## Common gotchas
- Middleware MUST call await next() after setting variables - forgetting it silently drops the handler
- c.req.json() is consumed on first call in Workers - use c.set() to pass the body to handlers
- Workers export: export default app (no port, no fetch key)
- Bun export: export default { port: 3000, fetch: app.fetch }
- c.body() requires explicit Content-Type header - use c.json(), c.text(), c.html() instead when possible

For the Bun-specific runtime context including Bun.serve, native SQLite, and shell scripting patterns, Claude Code with Bun covers the conventions that compose with the Hono-on-Bun setup shown here.

Building Hono APIs that Claude maintains cleanly

The Hono CLAUDE.md in this guide produces code where the Context object is the only interface to requests and responses, runtime export signatures are correct for the declared target, environment bindings are accessed through c.env instead of process.env, validation is handled by zValidator middleware with typed c.req.valid() access in handlers, and error handling routes all thrown exceptions through the global app.onError().

The underlying principle is the same as every framework integration with Claude Code. Hono's runtime-agnostic design is its strength, but that same flexibility means Claude has to make assumptions when no guidance is present. It assumes Node because Node is most common, reaches for Express patterns because they are most familiar, and generates app.listen() because that is what most "start an HTTP server" examples show. A CLAUDE.md removes those assumptions one by one.

The runtime-explicit pattern from this guide composes directly with the Cloudflare deployment workflow. Once your wrangler.toml bindings are declared and your Env interface matches them, Claude generates D1 queries, KV operations, and Workers environment access correctly on the first attempt.

For the CLAUDE.md mechanics, including how it is read at session start and how to version it alongside your application, see CLAUDE.md explained. Claudify includes a Hono-specific CLAUDE.md template pre-configured for the Context object rules, runtime export signatures, zValidator patterns, RPC mode, and the gotchas documented above.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame