Hono 4.x (TypeScript edge web framework) rules for Cursor. Pinned to hono ^4.12.19, @hono/zod-validator ^0.8.0, @hono/zod-openapi ^1.4.0 (zod ^4.x peer), @hono/node-server ^2.0.3 (Node 20+). Leads with v4-era APIs that pre-2025 LLMs miss: c.json() always returns TypedResponse (since v4.3.0), validator throws HTTPException instead of returning, getCookie/setCookie from hono/cookie (c.req.cookie removed), c.env property access (c.env() function removed), stream/streamText/streamSSE from hono/streaming (c.stream removed), showRoutes from hono/dev (app.showRoutes removed), getConnInfo helper for runtime IP, fire(app) from hono/service-worker (app.fire deprecated v4.8.0), Workers Static Assets binding (serveStatic from hono/cloudflare-workers deprecated v4.3.0), JSR @hono/hono on Deno (deno.land/x stale v4.4.0+), @hono/node-server v2 requires Node 20+. Catches 50+ regressions including Express-style res.json()/req.params/(req,res,next)/(err,req,res,next)/bodyParser leakage, npm cors over hono/cors, supertest over app.request, c.req.body over awaited c.req.json(), forgotten import type on hc<AppType> (the #1 RPC bundle-bloat pitfall), Rails-style controllers losing path-param type inference, exporting app before chain finishes (drops RPC types), zValidator placement after handler (TS error), c.notFound() breaking RPC type union, raw new Response() in handlers breaking RPC, relative URL passed to hc(), untyped c.userId = inline assignments (no Variables generic), c.executionCtx.waitUntil throwing on Bun, process.env in Workers (should be c.env), fs/path imports in Workers, node_compat vs nodejs_compat, missing waitUntil for fire-and-forget, unawaited c.env.DB.prepare().run(), no secureHeaders middleware, no csrf middleware on POST, cors origin '*' with credentials, missing bodyLimit + CVE-2025-59139, cookie missing httpOnly/secure/sameSite, SameSite=None without Secure, PII in logger, no etag/cache on GET, hard-coded JWT secret, JSON build in memory instead of streamSSE, sub-app notFound that never fires, trailing-slash inconsistency, double next() call, basePath used as statement, throw redirect/throw error patterns leaking from SvelteKit prompts, Lucia auth (deprecated 2025-03, recommend Better Auth or Clerk), @hono/sentry (deprecated, use @sentry/hono), wrangler.toml over wrangler.jsonc on greenfield, missing nodejs_compat flag, hono/cache on workers.dev subdomain (no-op), @hono/zod-openapi paired with zod 3.x (peer is ^4 only), Prisma + Hyperdrive without driver adapter, RPC at 30+ routes without TypeScript project references (tsserver hits 8-minute builds). NO Hono v5 references (none exists; latest stable is v4.12.19).
Run the plugin's validate-plugin.sh, then tsc --noEmit, then app-level tests, then a grep audit for syntactic anti-patterns the type checker won't catch (c.jsonT, c.stream as Context method, c.req.cookie, c.env() function call, app.showRoutes, app.handleEvent, app.fire(), hono/nextjs, hono/middleware barrel, serveStatic from hono/cloudflare-workers, deno.land/x/hono, supertest, process.env in Workers, node_compat flag, hard-coded JWT secret, c.notFound() in RPC routes, raw new Response in RPC routes, missing await on D1 .run/.first/.all, missing return on c.json calls, sameSite:'None' without secure:true, cors origin:'*' with credentials:true, missing bodyLimit on POST routes, missing import type on hc imports). Refuses to ship if any error fires. Pairs with all hono rules.
# Validate a Hono Plugin / App
## When to use
Use when:
- You finished a Hono feature and want to verify it before opening a PR
- A reviewer asked you to run the validation suite
- You suspect a v3-era API leaked back in (LLM-generated code)
Pairs with every `hono-*` rule. Refuses to ship if anything fires.
## Workflow
### 1. Run the plugin's validation script
```bash
./tests/validation/validate-plugin.sh
```
Must exit `0`. The script checks:
- `.cursor-plugin/plugin.json` is valid JSON with `name`, `version`, `description`, `author`, `license`
- Each `rules/*.mdc` has YAML frontmatter with `description`, `globs` (or `alwaysApply: true`)
- Each `skills/*/SKILL.md` has YAML frontmatter with `name` (matching directory) and `description`
- Each `agents/*.md` has YAML frontmatter with `name` and `description`
- `README.md`, `LICENSE`, `CHANGELOG.md` exist
- No em dashes (U+2014) in any rule / skill / agent / README / CHANGELOG
- No emojis in any rule / skill / agent / README / CHANGELOG
- No "Hono v5" references anywhere (no v5 exists)
- `correct-sample` fixture has zero banned-pattern violations
- `anti-pattern-sample` fixture has >= 8 tracked violations
### 2. TypeScript check
```bash
pnpm tsc --noEmit
```
Must exit `0`. The TS errors that surface here are usually v3 -> v4 migration items: `c.jsonT`, `c.stream`, `c.env()` function form, `c.req.cookie`, removed `HonoRequest` accessor methods, `FC` without explicit children.
### 3. App-level tests
```bash
pnpm test
```
If using Vitest + `@cloudflare/vitest-pool-workers`, this runs inside the real workerd runtime with D1 / KV / R2 bindings.
### 4. Syntactic anti-pattern grep audit
Run these `grep` commands from the project root. Each one MUST return zero hits in `src/` (excluding tests / docs / migrations / generated `worker-configuration.d.ts`):
```bash
# v3-era removed APIs
grep -rn "c\.jsonT\b" src/
grep -rn "c\.streamText\(" src/ # the Context method form
grep -rn "c\.stream\(" src/ # the Context method form
grep -rn "c\.req\.cookie\(" src/
grep -rn "c\.env\(\)" src/
grep -rn "app\.showRoutes\(" src/
grep -rn "app\.handleEvent\(" src/
grep -rn "app\.fire\(" src/
grep -rn "app\.head\(" src/
grep -rn "from ['\"]hono/nextjs['\"]" src/
grep -rn "from ['\"]hono/middleware['\"]" src/
grep -rn "from ['\"]hono/cloudflare-workers['\"]" src/ | grep -i 'serveStatic'
grep -rn "from ['\"]https://deno\.land/x/hono" src/
# Workers gotchas
grep -rn "process\.env\." src/
grep -rn "node_compat" wrangler.toml wrangler.jsonc 2>/dev/null
# Express leakage
grep -rn "import.*supertest" src/ tests/
grep -rn "req\.body\b" src/ | grep -v 'c\.req\.body' | grep -v 'c\.req\.raw\.body'
grep -rn "res\.json\(" src/
grep -rn "(req, res, next)" src/
grep -rn "(err, req, res, next)" src/
grep -rn "from ['\"]cors['\"]" src/ # npm cors, not hono/cors
# Security
grep -rn "sameSite:\s*['\"]None['\"]" src/ # must always pair with secure:true
grep -rn "origin:\s*['\"]\*['\"]" src/ | grep -i 'credentials'
grep -rn "jwt({.*secret:\s*['\"]" src/ # hard-coded JWT secret string
# RPC bundle bloat
grep -rn "import\b.*[^t]\s*AppType" src/ # ensure import type, not value import
```
If any hit fires, fix the underlying code (see `hono-anti-patterns` rule for the BAD/CORRECT pair).
### 5. Optional: `hanko` plugin manifest validation
For projects that ship Claude Code plugin manifests:
```bash
hanko validate .
```
Cursor plugins use `.cursor-plugin/plugin.json` (not the Claude Code shape), so `hanko` does not apply here. The validate-plugin.sh script detects and skips this case automatically.
### 6. Smoke test the app
```bash
pnpm dev
```
In a separate terminal:
```bash
curl http://localhost:8787/ # main route
curl -X POST http://localhost:8787/api/users \
-H 'content-type: application/json' \
-d '{"name": "Jane"}' # validator path
curl -X POST http://localhost:8787/api/users \
-H 'content-type: application/json' \
-d '{ malformed' # validator throws -> 400
curl http://localhost:8787/api/users/00000000-0000-0000-0000-000000000000 # 404 via c.json typed error
```
Expected: 200, 201, 400, 404 with JSON shapes matching your handlers.
## Decision tree
| Outcome | Action |
|---|---|
| `validate-plugin.sh` exit 0, `tsc` exit 0, tests pass, no grep hits | Ship |
| `validate-plugin.sh` reports em dash / emoji | Replace before commit |
| `tsc` errors on `c.jsonT` / `c.stream` / etc. | Run `/hono-migrate-to-v4` |
| Tests fail with "Context is not finalized" | Find the handler missing `return c.json(...)` (anti-pattern 1) |
| Tests fail with `c.req.valid('json')` is `never` | Move `zValidator` from `app.use` to route arg (anti-pattern 28) |
| Grep hit on `c.req.cookie` | Replace with `getCookie(c, 'name')` (anti-pattern 16) |
| Grep hit on `serveStatic` from `hono/cloudflare-workers` | Migrate to asset binding (anti-pattern 37) |
| Grep hit on `process.env.X` | Replace with `c.env.X` and add to `Bindings` (anti-pattern 34) |
## What this skill does NOT do
- Run end-to-end browser tests (use Playwright separately)
- Run load tests
- Lint TypeScript style (use ESLint / Biome separately)
- Audit npm dependencies for CVEs (use `npm audit` / Snyk / Dependabot)Scaffold a fresh Hono v4 + Cloudflare Workers project with wrangler.jsonc (NOT wrangler.toml), compatibility_date 2026-05-01 + compatibility_flags ['nodejs_compat'], typed Bindings generic generated from wrangler types, asset binding for static files (NOT serveStatic from hono/cloudflare-workers - deprecated v4.3.0), D1 + Drizzle middleware pattern (drizzle(c.env.DB) injected via c.set into c.var.db), secureHeaders + cors + bodyLimit + csrf middleware stack, hono >= 4.12.19 + wrangler ^4.0.0 + drizzle-orm latest, app.onError + app.notFound at top-level, ES module export default { fetch: app.fetch } with sibling scheduled/queue handlers, vitest + @cloudflare/vitest-pool-workers for real-runtime tests with applyD1Migrations fixture pattern. Refuses wrangler.toml on greenfield, node_compat flag, deprecated serveStatic from hono/cloudflare-workers, process.env access, fs/path imports, export default app without .fetch, hard-coded JWT secret. Pairs with hono-cloudflare-workers, hono-core, hono-security, hono-anti-patterns rules.
# Scaffold a Fresh Hono + Cloudflare Workers Project
## When to use
Use when:
- Starting a new Hono project targeting Cloudflare Workers
- Migrating an existing app to Workers + D1 + Drizzle
- A reviewer flagged a `wrangler.toml` on a greenfield project (Cloudflare moved examples to JSONC in 2025)
- A reviewer flagged a deprecated `serveStatic` import
Target: `hono ^4.12.19`, `wrangler ^4.0.0`, `drizzle-orm` latest, `@hono/zod-validator ^0.8.0`. The companion `hono-cloudflare-workers`, `hono-core`, `hono-security`, and `hono-anti-patterns` rules cover the surrounding patterns.
## Project layout
```
my-hono-app/
├── package.json
├── tsconfig.json
├── wrangler.jsonc
├── worker-configuration.d.ts # generated by `wrangler types`
├── drizzle.config.ts
├── drizzle/ # migrations
├── public/ # static assets (served via asset binding)
├── src/
│ ├── index.ts # entry point
│ ├── routes/
│ │ └── users.ts # chained sub-app
│ ├── middleware/
│ │ └── db.ts # injects Drizzle into c.var.db
│ ├── db/
│ │ └── schema.ts # Drizzle schema
│ └── lib/
│ └── env.ts # Bindings + Variables type aliases
└── tests/
└── index.test.ts # vitest-pool-workers
```
## `wrangler.jsonc`
```jsonc
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-hono-app",
"main": "src/index.ts",
"compatibility_date": "2026-05-01",
"compatibility_flags": ["nodejs_compat"],
"observability": { "enabled": true },
"vars": { "ENV": "production", "LOG_LEVEL": "info" },
"d1_databases": [
{ "binding": "DB", "database_name": "my-hono-app-prod", "database_id": "REPLACE_ME" }
],
"kv_namespaces": [
{ "binding": "SESSIONS", "id": "REPLACE_ME" }
],
"r2_buckets": [
{ "binding": "UPLOADS", "bucket_name": "my-hono-app-uploads" }
],
"assets": {
"directory": "./public",
"binding": "ASSETS"
}
}
```
## `package.json`
```json
{
"name": "my-hono-app",
"type": "module",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:migrate:local": "wrangler d1 migrations apply DB --local",
"db:migrate:remote": "wrangler d1 migrations apply DB --remote"
},
"dependencies": {
"hono": "^4.12.19",
"@hono/zod-validator": "^0.8.0",
"drizzle-orm": "^0.45.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@cloudflare/vitest-pool-workers": "^0.7.0",
"@cloudflare/workers-types": "^4.0.0",
"drizzle-kit": "^0.30.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0",
"wrangler": "^4.0.0"
}
}
```
## `tsconfig.json`
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "worker-configuration.d.ts"]
}
```
## `src/lib/env.ts`
```ts
import type { DrizzleD1Database } from 'drizzle-orm/d1'
import type * as schema from '../db/schema'
export type Bindings = Env // generated by `wrangler types`
export type Variables = {
db: DrizzleD1Database<typeof schema>
requestId: string
}
export type AppEnv = { Bindings: Bindings; Variables: Variables }
```
## `src/middleware/db.ts`
```ts
import { drizzle } from 'drizzle-orm/d1'
import { createMiddleware } from 'hono/factory'
import type { AppEnv } from '../lib/env'
import * as schema from '../db/schema'
export const dbMiddleware = createMiddleware<AppEnv>(async (c, next) => {
c.set('db', drizzle(c.env.DB, { schema }))
await next()
})
```
## `src/index.ts`
```ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { cors } from 'hono/cors'
import { csrf } from 'hono/csrf'
import { requestId } from 'hono/request-id'
import { HTTPException } from 'hono/http-exception'
import { dbMiddleware } from './middleware/db'
import users from './routes/users'
import type { AppEnv } from './lib/env'
const app = new Hono<AppEnv>()
.use('*', logger())
.use('*', requestId())
.use('*', secureHeaders())
.use('/api/*', cors({
origin: ['https://example.com'],
credentials: true,
}))
.use('/api/*', csrf({ origin: 'https://example.com' }))
.use('*', dbMiddleware)
.get('/static/*', (c) => c.env.ASSETS.fetch(c.req.raw))
.route('/api/users', users)
.notFound((c) => c.json({ error: 'not_found' }, 404))
.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse()
console.error(err)
return c.json({ error: 'internal' }, 500)
})
export type AppType = typeof app
export default {
fetch: app.fetch,
} satisfies ExportedHandler<Env>
```
## `src/db/schema.ts`
```ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
})
```
## `drizzle.config.ts`
```ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'sqlite',
driver: 'd1-http',
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID!,
token: process.env.CLOUDFLARE_API_TOKEN!,
},
})
```
## `vitest.config.ts`
```ts
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
},
},
},
})
```
## Rules baked into the scaffold
1. **`wrangler.jsonc` not `wrangler.toml`** (Cloudflare's 2025+ canonical shape).
2. **`compatibility_flags: ["nodejs_compat"]`** (umbrella). NEVER `node_compat` (anti-pattern 36).
3. **Asset binding for static files**, NOT `serveStatic` from `hono/cloudflare-workers` (anti-pattern 37).
4. **`Bindings` generic populated via `wrangler types`.** Strongly typed `c.env`.
5. **`c.env` access throughout**, never `process.env` (anti-pattern 34).
6. **D1 via Drizzle, injected via middleware** into `c.var.db`.
7. **`secureHeaders` + `cors` + `csrf` registered** before routes.
8. **`app.onError` + `app.notFound` at top level** (sub-app `notFound` is dead code).
9. **`export default { fetch: app.fetch }`**, never `export default app` (anti-pattern 55).
10. **Vitest config uses `@cloudflare/vitest-pool-workers`** for real-runtime tests with D1.
## Workflow
1. **Scaffold:** copy the files above into a new directory.
2. **`pnpm install`** (or npm / bun / yarn).
3. **Create the D1 database:** `wrangler d1 create my-hono-app-prod`, paste the `database_id` into `wrangler.jsonc`.
4. **Generate the binding type:** `pnpm types`.
5. **Generate the first migration:** `pnpm db:generate`, then `pnpm db:migrate:local`.
6. **Run locally:** `pnpm dev` (Miniflare with real D1).
7. **Deploy:** `pnpm db:migrate:remote && pnpm deploy`.
## Variations
### With Better Auth + Drizzle on D1
Add `better-auth` and wire its handler under `/api/auth/*`:
```ts
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
const auth = betterAuth({
database: drizzleAdapter(drizzle(env.DB), { provider: 'sqlite' }),
})
app.on(['GET', 'POST'], '/api/auth/*', (c) => auth.handler(c.req.raw))
```
### With Hyperdrive + Postgres + Prisma
REQUIRED: `@prisma/adapter-pg`. Local Hyperdrive does NOT support SSL.
```ts
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { Pool } from 'pg'
app.use('*', async (c, next) => {
const pool = new Pool({ connectionString: c.env.HYPERDRIVE.connectionString })
const adapter = new PrismaPg(pool)
c.set('db', new PrismaClient({ adapter }))
await next()
})
```
### With Durable Objects for WebSockets
See the `hono-cloudflare-workers` rule's "WebSockets via Durable Objects" section.
## Common mistakes the scaffold refuses
- `wrangler.toml` instead of `wrangler.jsonc` on a greenfield project.
- `compatibility_flags: ["node_compat"]` (legacy flag).
- `import { serveStatic } from 'hono/cloudflare-workers'`.
- `import fs from 'fs'` anywhere in Workers code.
- `process.env.X` anywhere.
- `export default app` (must be `export default { fetch: app.fetch }`).
- D1 calls without `await`.
- `jwt({ secret: 'hardcoded' })` constant secret.
## What this skill does NOT scaffold
- A new route. See `/hono-new-route`.
- The RPC client. See `/hono-rpc-setup`.
- A migration from Hono v3. See `/hono-migrate-to-v4`.Stage-by-stage migration from Hono v3 to v4: bump hono pin to ^4.12.19 (mandatory >= 4.9.7 for CVE-2025-59139), replace c.jsonT() with c.json() (always returns TypedResponse), replace c.stream/c.streamText Context methods with stream/streamText/streamSSE from hono/streaming, replace c.req.cookie/c.cookie with getCookie/setCookie from hono/cookie, replace c.env() function call with c.env property + getRuntimeKey() from hono/adapter for runtime detection, replace app.showRoutes()/app.routerName with showRoutes/getRouterName from hono/dev, replace addEventListener('fetch') Service Worker entry with export default { fetch: app.fetch }, drop the hono/middleware barrel import for per-middleware subpath imports, replace c.req.headers()/body()/signal() accessor methods with c.req.raw.headers/.body/.signal, replace LambdaFunctionUrlRequestContext with ApiGatewayRequestContextV2, replace hono/nextjs with hono/vercel, drop app.head() routes (HEAD auto-derived from GET), update FC to require explicit PropsWithChildren<P>, replace validator-returns-response pattern with validator-throws-HTTPException + register app.onError, replace c.req as raw Request access with c.req.raw, Deno-only: switch import from deno.land/x to jsr:@hono/hono, @hono/node-server v2 requires Node 20+ and drops the /vercel adapter. Companion to hono-anti-patterns rule entries 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23.
# Migrate from Hono v3 to v4
## When to use
Use when:
- The project is pinned to `hono ^3.x` and needs to move to `^4.x`
- A reviewer flagged `c.jsonT`, `c.stream`, `c.env()`, `c.req.cookie`, `app.showRoutes`, or the `hono/middleware` barrel
- You see a build error that traces back to a v4 removed API
Target end state: `hono ^4.12.19` (mandatory `>= 4.9.7` for CVE-2025-59139). The companion `hono-anti-patterns` rule rejects each removed API by name (entries 13-23).
## Reference
Hono's official migration guide: https://github.com/honojs/hono/blob/main/docs/MIGRATION.md
## Stage-by-stage
### Stage 0: pin + sanity check
```bash
# Check current version
npm ls hono
# Read the full migration guide once
cat node_modules/hono/docs/MIGRATION.md
```
### Stage 1: bump pins and reinstall
```json
{
"dependencies": {
"hono": "^4.12.19"
},
"devDependencies": {
"@hono/node-server": "^2.0.3",
"@hono/zod-validator": "^0.8.0"
}
}
```
If on Node: `@hono/node-server >= 2.0.0` requires **Node 20+** AND removed the bundled `/vercel` adapter. Bump Node first if needed.
```bash
pnpm install
pnpm tsc --noEmit
```
The TypeScript errors that surface next ARE the v4 migration list.
### Stage 2: response builders
```ts
// BEFORE (v3)
return c.jsonT({ ok: true })
// AFTER (v4)
return c.json({ ok: true }) // c.json now ALWAYS returns TypedResponse
```
`c.text()` also gained `TypedResponse` since v4.3.0 - RPC clients can discriminate text payloads.
### Stage 3: streaming
```ts
// BEFORE (v3 - Context methods)
return c.streamText(async (stream) => { await stream.write('hi') })
// AFTER (v4 - helper imports)
import { streamText, stream, streamSSE } from 'hono/streaming'
app.get('/', (c) => streamText(c, async (s) => { await s.write('hi') }))
app.get('/sse', (c) => streamSSE(c, async (s) => { await s.writeSSE({ data: 'tick' }) }))
```
### Stage 4: cookies
```ts
// BEFORE (v3)
const v = c.req.cookie('session')
c.cookie('session', token, { httpOnly: true })
// AFTER (v4 - hono/cookie helpers)
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
const v = getCookie(c, 'session')
setCookie(c, 'session', token, {
httpOnly: true,
secure: true,
sameSite: 'Lax',
path: '/',
})
deleteCookie(c, 'session', { path: '/' })
```
### Stage 5: `c.env` and runtime detection
```ts
// BEFORE (v3)
const runtime = c.env() // function form returned runtime adapter
// AFTER (v4)
import { getRuntimeKey } from 'hono/adapter'
const runtime = getRuntimeKey() // 'workers' | 'bun' | 'deno' | 'node' | ...
// c.env is now ONLY an object of bindings:
const dbBinding = c.env.DB
```
### Stage 6: dev helpers
```ts
// BEFORE (v3)
app.showRoutes()
console.log(app.routerName)
// AFTER (v4)
import { showRoutes, getRouterName } from 'hono/dev'
showRoutes(app, { verbose: true })
console.log(getRouterName(app))
```
### Stage 7: Workers entry point
```ts
// BEFORE (v3 - Service Worker syntax)
addEventListener('fetch', (event) => app.handleEvent(event))
// AFTER (v4 - ES Module)
export default {
fetch: app.fetch,
}
```
If you also want `scheduled` / `queue` / `email` handlers:
```ts
export default {
fetch: app.fetch,
scheduled: async (event, env, ctx) => { /* ... */ },
queue: async (batch, env, ctx) => { /* ... */ },
email: async (message, env, ctx) => { /* ... */ },
} satisfies ExportedHandler<Env>
```
### Stage 8: middleware imports
```ts
// BEFORE (v3 - barrel import)
import { logger, cors, bearerAuth } from 'hono/middleware'
// AFTER (v4 - per-middleware subpath)
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { bearerAuth } from 'hono/bearer-auth'
```
### Stage 9: `HonoRequest` accessor methods
```ts
// BEFORE (v3 - method form)
c.req.headers()
c.req.body()
c.req.signal()
c.req.referrer()
c.req.keepalive()
c.req.integrity()
// AFTER (v4 - property form on raw Request)
c.req.raw.headers
c.req.raw.body
c.req.raw.signal
c.req.raw.referrer
c.req.raw.keepalive
c.req.raw.integrity
```
### Stage 10: AWS Lambda
```ts
// BEFORE (v3)
import { LambdaFunctionUrlRequestContext } from 'hono/aws-lambda'
// AFTER (v4 - consolidated)
import { ApiGatewayRequestContextV2 } from 'hono/aws-lambda'
```
### Stage 11: Next.js adapter
```ts
// BEFORE (v3)
import { handle } from 'hono/nextjs'
// AFTER (v4 - hono/vercel covers it)
import { handle } from 'hono/vercel'
```
### Stage 12: `app.head` removed
```ts
// BEFORE (v3)
app.head('/health', (c) => c.body(null, 200))
// AFTER (v4 - HEAD is auto-derived from GET)
app.get('/health', (c) => c.text('ok'))
```
### Stage 13: JSX `FC` no longer includes children
```ts
// BEFORE (v3)
import type { FC } from 'hono/jsx'
const Layout: FC = (props) => <html><body>{props.children}</body></html>
// AFTER (v4)
import type { PropsWithChildren } from 'hono/jsx'
const Layout = ({ children }: PropsWithChildren) => (
<html><body>{children}</body></html>
)
```
### Stage 14: validator throws now
```ts
// v4 - validators THROW HTTPException(400) on failure
// (v3 returned a response directly)
// You MUST register app.onError to handle the throw:
import { HTTPException } from 'hono/http-exception'
app.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse()
console.error(err)
return c.json({ error: 'internal' }, 500)
})
```
To customize the validator's error response:
```ts
app.post('/users',
zValidator('json', schema, (result, c) => {
if (!result.success) {
return c.json({
error: 'validation_failed',
issues: result.error.issues,
}, 422)
}
}),
handler
)
```
### Stage 15: Deno - switch to JSR
```ts
// BEFORE (v3 / v4 < 4.4.0)
import { Hono } from 'https://deno.land/x/hono/mod.ts'
// AFTER (v4.4.0+)
import { Hono } from 'jsr:@hono/hono'
```
`deno.land/x/hono` is no longer updated. JSR (`jsr:@hono/hono`) is the supported channel.
### Stage 16: `@hono/node-server` v2 (Node only)
If on Node:
```ts
// BEFORE (v1)
import { serve } from '@hono/node-server'
serve(app)
// AFTER (v2)
import { serve } from '@hono/node-server'
serve({ fetch: app.fetch, port: 3000 })
```
v2 breaking changes:
- Drops Node 18 support (requires Node 20+)
- Removes the bundled `@hono/node-server/vercel` adapter (use `getRequestListener` to recreate it manually)
- Up to ~2.3x throughput improvement
- First-class WebSocket via `@hono/node-ws`
## Stage 17: `serveStatic` deprecation (Workers only)
```ts
// BEFORE (v3 / v4 < 4.3.0)
import { serveStatic } from 'hono/cloudflare-workers'
import manifest from '__STATIC_CONTENT_MANIFEST'
app.use('/static/*', serveStatic({ root: './', manifest }))
// AFTER - Workers Static Assets binding
// wrangler.jsonc:
// "assets": { "directory": "./public", "binding": "ASSETS" }
type Bindings = { ASSETS: Fetcher }
const app = new Hono<{ Bindings: Bindings }>()
app.get('/static/*', (c) => c.env.ASSETS.fetch(c.req.raw))
```
## Stage 18: `app.fire()` deprecated (v4.8.0+)
If using the Service Worker adapter:
```ts
// DEPRECATED since v4.8.0
app.fire()
// CORRECT
import { fire } from 'hono/service-worker'
fire(app)
```
## Stage 19: rerun tests
```bash
pnpm test
```
If you were on `supertest`, switch to `app.request()` or `testClient(app)`:
```ts
// BEFORE
import request from 'supertest'
request(app).get('/').expect(200)
// AFTER
import { testClient } from 'hono/testing'
const client = testClient(app)
const res = await client.users.$get()
```
## Stage 20: verify security pins
After migration, ensure:
- `hono >= 4.9.7` (CVE-2025-59139 / GHSA-92vj-g62v-jqhh - bodyLimit bypass)
- `hono >= 4.12.18` if rendering JSX SSR (tag / attribute / CSS injection hardening)
- `@hono/sentry` replaced by `@sentry/hono` (community middleware deprecated by Sentry)
## Workflow summary
```bash
# 1. Bump pins
pnpm add hono@^4.12.19 @hono/node-server@^2.0.3 @hono/zod-validator@^0.8.0
# 2. Type-check; the errors are the migration list
pnpm tsc --noEmit
# 3. Address removed APIs (stages 2-18 above)
# 4. Add app.onError to handle validator throws
# 5. Switch Deno imports to jsr:@hono/hono
# 6. Replace supertest with app.request / testClient
# 7. Run tests
pnpm test
# 8. Smoke test the dev server
pnpm dev
```
## Common mistakes during migration
- Forgetting to bump Node to 20+ when upgrading `@hono/node-server` to v2
- Leaving `c.jsonT(...)` calls (TypeScript catches sync; async variant compiles but throws)
- Mixing `hono/middleware` barrel with per-middleware subpath imports (deduplicate)
- Skipping `app.onError` after validators started throwing
- Forgetting to bump `compatibility_date` past 2024-09-23 when switching to `nodejs_compat`
## What this skill does NOT do
- Migrate from Express to Hono - that is a rewrite, not a migration. The `hono-routing-and-rpc` rule covers the Express -> Hono mapping table.
- Migrate from Fastify to Hono.
- Migrate from Hono v2 to v4 - run the v2 -> v3 stage first using Hono's migration doc.Scaffold a Hono v4 route with chained-route inference for RPC, zValidator placed as a route argument (NOT app.use), c.req.valid('json') typed access, c.req.param() typed from path, c.json() with discriminated-union returns for typed errors over RPC (NOT c.notFound, NOT new Response), c.json(..., 200) for typed status codes, optional Bindings + Variables generics on the Hono instance. Refuses Rails-style controllers (loses path-param inference per Hono Best Practices), zValidator before the handler instead of as a route arg, c.notFound() in RPC-consumed routes, raw new Response() in RPC routes. Pairs with hono-core, hono-routing-and-rpc, hono-validators, hono-anti-patterns rules.
# Scaffold a New Hono v4 Route
## When to use
Use when:
- Adding a new endpoint to a Hono app or sub-app
- Converting an Express route to Hono
- A reviewer flagged a route losing RPC type inference because it was defined as a statement instead of chained
Target: `hono ^4.12.19`, `@hono/zod-validator ^0.8.0`. The companion `hono-core`, `hono-routing-and-rpc`, `hono-validators`, and `hono-anti-patterns` rules cover the surrounding patterns.
## Output: a single chained sub-app
For a feature `/users`, the scaffold writes `src/routes/users.ts` and wires it into `src/index.ts`.
### `src/routes/users.ts`
```ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { HTTPException } from 'hono/http-exception'
type Bindings = { DB: D1Database }
type Variables = { userId: string }
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
})
const idParamSchema = z.object({
id: z.string().uuid(),
})
const users = new Hono<{ Bindings: Bindings; Variables: Variables }>()
.get('/', async (c) => {
const rows = await c.env.DB.prepare('SELECT id, name, email FROM users').all()
return c.json({ users: rows.results } as const)
})
.get('/:id', zValidator('param', idParamSchema), async (c) => {
const { id } = c.req.valid('param')
const row = await c.env.DB.prepare('SELECT id, name, email FROM users WHERE id = ?')
.bind(id)
.first()
if (!row) return c.json({ error: 'not_found' as const }, 404)
return c.json({ user: row } as const, 200)
})
.post('/', zValidator('json', createUserSchema), async (c) => {
const body = c.req.valid('json')
const id = crypto.randomUUID()
await c.env.DB.prepare('INSERT INTO users (id, name, email) VALUES (?, ?, ?)')
.bind(id, body.name, body.email)
.run()
return c.json({ user: { id, ...body } } as const, 201)
})
.delete('/:id', zValidator('param', idParamSchema), async (c) => {
const { id } = c.req.valid('param')
const result = await c.env.DB.prepare('DELETE FROM users WHERE id = ?').bind(id).run()
if (result.meta.changes === 0) {
return c.json({ error: 'not_found' as const }, 404)
}
return c.body(null, 204)
})
export default users
export type UsersApp = typeof users
```
### Wiring it into `src/index.ts`
```ts
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { secureHeaders } from 'hono/secure-headers'
import { HTTPException } from 'hono/http-exception'
import users from './routes/users'
const app = new Hono<{ Bindings: Env }>()
.use('*', logger())
.use('*', secureHeaders())
.route('/users', users)
.notFound((c) => c.json({ error: 'not_found' }, 404))
.onError((err, c) => {
if (err instanceof HTTPException) return err.getResponse()
console.error(err)
return c.json({ error: 'internal' }, 500)
})
export type AppType = typeof app
export default { fetch: app.fetch } satisfies ExportedHandler<Env>
```
## Rules baked into the scaffold
1. **Routes are chained.** Every `.get` / `.post` / `.put` / `.delete` returns a new Hono with the route added to its type. Statement form throws the type away. (anti-pattern 25)
2. **Sub-apps mounted with chained `.route`.** Same rule. (anti-pattern 26)
3. **`zValidator` placed as a ROUTE argument, not `app.use`.** Validators contribute to the per-route type; global registration breaks `c.req.valid()`. (anti-pattern 28)
4. **`c.req.valid('json' | 'param' | 'query')` for typed body / params / query.** Never destructure `await c.req.json()` and then validate by hand.
5. **`c.json({ ... } as const, status)` for typed errors over RPC.** Never `c.notFound()` in RPC-consumed routes. (anti-pattern 29)
6. **Never return raw `new Response(...)` from RPC-consumed routes.** Use `c.json()` to keep client types. (anti-pattern 30)
7. **`Bindings` and `Variables` generics declared.** Without them `c.env` is `{}` and `c.get`/`c.set` are unchecked. (anti-pattern 24)
8. **Sub-app `notFound` NOT defined.** Only top-level `notFound` fires. (anti-pattern 50)
9. **`app.onError` registered to catch `HTTPException` from validators.** v4 validators throw on invalid input.
10. **D1 statements always awaited.** `.run()` / `.first()` / `.all()` return Promises. (anti-pattern 40)
## Workflow
1. **Choose the feature path.** `/users`, `/posts`, etc. Put the sub-app at `src/routes/<feature>.ts`.
2. **Declare schemas first.** Zod schemas at the top of the file.
3. **Chain every route.** Each `.get` / `.post` returns the Hono with the new route in its type. Export `typeof users`.
4. **Mount in `src/index.ts`.** `app.route('/users', users)`, chained.
5. **Register `app.notFound` and `app.onError` at the top level.**
## Variations
### Multi-validator route
```ts
.post('/:id/comments',
zValidator('param', z.object({ id: z.string().uuid() })),
zValidator('json', commentSchema),
zValidator('header', z.object({ 'x-request-id': z.string().optional() })),
async (c) => {
const { id } = c.req.valid('param')
const body = c.req.valid('json')
const headers = c.req.valid('header')
return c.json({ id, body, headers } as const, 201)
}
)
```
### Custom validator error response
```ts
.post('/',
zValidator('json', createUserSchema, (result, c) => {
if (!result.success) {
return c.json({
error: 'validation_failed' as const,
issues: result.error.issues,
}, 422)
}
}),
handler
)
```
### Sub-app with its own middleware
```ts
const users = new Hono<{ Bindings: Bindings }>()
.use('*', async (c, next) => {
const auth = c.req.header('authorization')
if (!auth) throw new HTTPException(401, { message: 'Unauthorized' })
await next()
})
.get('/', listHandler)
.get('/:id', getHandler)
```
### Background work with `waitUntil` (Workers only)
```ts
.post('/event', async (c) => {
const body = await c.req.json()
c.executionCtx.waitUntil(sendAnalytics(body))
return c.json({ ok: true } as const, 202)
})
```
For runtime portability, wrap `c.executionCtx.waitUntil` in try/catch (anti-pattern 39).
## Common mistakes the scaffold refuses
- `app.get('/users', getUsers)` followed by `app.post('/users', createUser)` as separate statements (loses RPC types).
- `app.use('/users', zValidator('json', schema))` (TS error on `c.req.valid('json')`).
- `if (!user) return c.notFound()` in a route the RPC client consumes (anti-pattern 29).
- `return new Response(JSON.stringify(...))` in RPC routes (client sees `unknown`).
- Sub-app `users.notFound(...)` calls (silently dead code).
- `c.env.DB.prepare(...).run()` without `await` (insert may be cancelled when worker terminates).
## What this skill does NOT scaffold
- The Workers project itself. See `/hono-cloudflare-workers-setup`.
- The RPC client. See `/hono-rpc-setup`.
- A migration from v3. See `/hono-migrate-to-v4`.
- Tests. See the `hono-testing` rule.Scaffold the Hono v4 RPC client (hc<typeof app>) wired against a typed server app. Critical setup: export type AppType = typeof app from the server (NOT export default app), import type { AppType } on the client (NEVER value-import; that drags server bundle into the browser - the #1 RPC bundle-bloat pitfall), absolute URL passed to hc() (relative URLs throw at runtime), InferRequestType / InferResponseType for TanStack Query / SWR integration, parseResponse client utility for typed error throws, $path() method for typed URL construction, $url() requires absolute base URL. Refuses: relative-URL hc('/'), value-import of the server app, c.notFound() in RPC routes (cannot type), raw new Response() in RPC routes (cannot type), unchained server routes (drops the type). Pairs with hono-routing-and-rpc, hono-core, hono-anti-patterns rules.
# Scaffold the Hono v4 RPC Client
## When to use
Use when:
- Setting up the typed `hc<AppType>` client for the first time
- Debugging "where did my types go" on the RPC client (you imported the server module as a value instead of `import type`)
- Wiring `hc` into TanStack Query / SWR
- A reviewer flagged a relative-URL `hc('/')` call
Target: `hono ^4.12.19`. The companion `hono-routing-and-rpc`, `hono-core`, and `hono-anti-patterns` rules cover the surrounding patterns.
## The two-file pattern (load-bearing)
### Server: `src/server/app.ts`
Chain every route. Export `typeof app` AND the running app.
```ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const createUserSchema = z.object({ name: z.string().min(1) })
const app = new Hono()
.get('/users', (c) => c.json([{ id: '1', name: 'Jane' }] as const))
.post('/users', zValidator('json', createUserSchema), (c) => {
const body = c.req.valid('json')
return c.json({ id: '2', ...body } as const, 201)
})
.get('/users/:id', (c) => {
const id = c.req.param('id')
return c.json({ id, name: 'Jane' } as const)
})
export type AppType = typeof app
export default app
```
### Client: `src/client/api.ts`
`import type` is LOAD-BEARING. Value-import drags `drizzle-orm`, `node:crypto`, `fs`, native deps into the browser bundle.
```ts
import type { AppType } from '../server/app' // TYPE-ONLY import
import { hc } from 'hono/client'
const baseUrl =
typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com'
export const api = hc<AppType>(baseUrl)
```
### Calling the API
```ts
import { api } from './client/api'
// GET /users
const usersRes = await api.users.$get()
if (usersRes.ok) {
const users = await usersRes.json() // typed [{ id, name }]
}
// POST /users { name }
const createRes = await api.users.$post({ json: { name: 'Jane' } })
if (createRes.status === 201) {
const created = await createRes.json()
}
// GET /users/:id
const userRes = await api.users[':id'].$get({ param: { id: '1' } })
const user = await userRes.json()
```
## Rules baked into the scaffold
1. **Server routes are chained.** `typeof app` only carries route types if every `.get` / `.post` / `.route` is chained. (anti-pattern 25)
2. **Server exports `export type AppType = typeof app`.** Type alias is the channel into the client.
3. **Client imports `import type { AppType }`.** Value-import breaks bundles. (anti-pattern 32)
4. **`hc<AppType>(absoluteUrl)`.** Relative URLs make `$url()` throw at runtime. (anti-pattern 31)
5. **Server uses `c.json(...)` for all RPC-consumed responses.** Raw `new Response()` breaks RPC types. (anti-pattern 30)
6. **`c.json({ error } as const, status)` for typed errors.** Never `c.notFound()` in RPC routes. (anti-pattern 29)
## TanStack Query integration
```ts
import { useQuery, useMutation } from '@tanstack/react-query'
import type { InferRequestType, InferResponseType } from 'hono/client'
import { api } from './api'
type CreateUserReq = InferRequestType<typeof api.users.$post>['json']
type UsersRes = InferResponseType<typeof api.users.$get>
function useUsers() {
return useQuery<UsersRes>({
queryKey: ['users'],
queryFn: async () => {
const res = await api.users.$get()
if (!res.ok) throw new Error('Failed to load users')
return res.json()
},
})
}
function useCreateUser() {
return useMutation({
mutationFn: async (input: CreateUserReq) => {
const res = await api.users.$post({ json: input })
if (!res.ok) throw new Error('Failed to create user')
return res.json()
},
})
}
```
For thinner setup, community helpers exist: `hono-rpc-query` (auto-generates `queryOptions`/`mutationOptions` per endpoint), `hono-rpc-swr` (SWR shape).
## `parseResponse` for typed error throws (v4.9.0+)
```ts
import { parseResponse } from 'hono/client'
try {
const data = await parseResponse(api.users.$get())
// typed success body
} catch (err) {
// structured error with status and parsed body
}
```
## `$path()` method (v4.12.0+)
Get the URL path string without making the request - useful for cache keys, programmatic navigation:
```ts
const path = api.users[':id'].$path({ id: '123' })
// '/users/123'
```
## Typed base URL (v4.11.0+)
```ts
const api = hc<AppType, 'https://api.example.com'>('https://api.example.com')
// $url() now infers the absolute URL
```
## Workflow
1. **Server side:** make sure the app is chained end to end. `export type AppType = typeof app`.
2. **Client side:** create a single `api.ts` module with `import type { AppType }` and the configured `hc<AppType>(url)`.
3. **Consumers** import `api` from that module. Never `import { hc } from 'hono/client'` everywhere.
4. **If you see `unknown` on `.json()`:** check (a) the server route was chained, (b) the client used `import type`.
## When the IDE slows down (30+ routes)
Reports of 8-minute CI builds and unusable autocomplete at large route graphs. Mitigations in order of impact:
1. **TypeScript project references.** Move the server into its own tsconfig project so `tsc` materializes types into `.d.ts` once and `tsserver` reads cached output. Canonical write-up: [Catalin Pit: Hono RPC and TypeScript Project References](https://catalins.tech/hono-rpc-in-monorepos/).
2. **Split into multiple sub-apps.** `hc<UsersApp>` and `hc<PostsApp>` separately instead of one `hc<RootApp>`.
3. **Specify type arguments manually** when chaining many `.get` / `.post`.
## Common mistakes the scaffold refuses
- `hc<AppType>('/')` - relative URL throws on `$url()`.
- `import { type AppType, default as app } from '../server/app'` - value-import drags server modules into client.
- Server routes defined as statements instead of chained.
- Server using `c.notFound()` or `new Response()` in RPC-consumed routes.
- Calling `hc()` per component instead of exporting a single `api` module.
## What this skill does NOT scaffold
- A new route. See `/hono-new-route`.
- A Workers project. See `/hono-cloudflare-workers-setup`.
- TanStack Query / SWR setup itself - just the typed bindings into Hono RPC.