Skip to content

Hono — Patterns

OWNER: urszula, maxim ALSO_USED_BY: sandro, boris, yoanna LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: 4.12.x


Overview

Routing, middleware chains, context, dependency injection, RPC mode, OpenAPI integration, and Zod validator patterns as used in GE services. All backend agents building API endpoints need this page.


Routing

Basic Route Definition

import { Hono } from 'hono'
import { AppEnv } from '../types/env'

const app = new Hono<AppEnv>()

app.get('/health', (c) => c.json({ status: 'ok' }))
app.get('/items/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ id })
})

CHECK: Every route handler returns a Response. IF: Handler has a code path that does not return. THEN: TypeScript will catch it. Fix it. Hono requires all handlers to return.

Modular Route Files

GE splits routes into one file per resource. Use app.route() to compose.

// src/routes/projects.ts
import { Hono } from 'hono'
import { AppEnv } from '../types/env'

export const projectRoutes = new Hono<AppEnv>()

projectRoutes.get('/', async (c) => {
  // List projects
  return c.json({ projects: [] })
})

projectRoutes.get('/:id', async (c) => {
  const id = c.req.param('id')
  return c.json({ id })
})

projectRoutes.post('/', async (c) => {
  // Create project
  return c.json({ created: true }, 201)
})
// src/app.ts
import { projectRoutes } from './routes/projects'

app.route('/api/projects', projectRoutes)

CHECK: Each route file creates its own new Hono<AppEnv>() instance. IF: You are importing the root app and adding routes directly. THEN: Refactor to use app.route(). Keeps route files independent.

Route Groups with Shared Middleware

const api = new Hono<AppEnv>()
api.use('*', authMiddleware)
api.route('/projects', projectRoutes)
api.route('/tasks', taskRoutes)

app.route('/api', api)

CHECK: Auth middleware is applied to the group, not repeated per route. IF: You see authMiddleware in every route file. THEN: Move it to the group level.


Middleware Chains

Hono middleware executes in onion order:

Request → MW1 start → MW2 start → Handler → MW2 end → MW1 end → Response
app.use('*', async (c, next) => {
  const start = Date.now()
  await next()
  const elapsed = Date.now() - start
  c.header('X-Response-Time', `${elapsed}ms`)
})

CHECK: Every middleware calls await next() exactly once. IF: You forget await next(). THEN: The request hangs. Downstream handlers never execute.

IF: You call next() without await. THEN: Post-handler logic runs before the handler finishes. Bugs guaranteed.

Middleware Registration Order

Order MATTERS. GE standard order:

  1. Request ID generation
  2. Error handler (wraps everything)
  3. CORS
  4. Logging
  5. Rate limiting
  6. Authentication
  7. Route handlers
app.use('*', requestIdMiddleware)   // 1
app.use('*', errorHandler)          // 2
app.use('*', cors(corsConfig))      // 3
app.use('*', logger())              // 4
app.use('/api/*', rateLimiter)      // 5
app.use('/api/*', authMiddleware)   // 6

Context (c)

The context object c provides request data and response helpers.

Reading Request Data

// Path params
const id = c.req.param('id')

// Query params
const page = c.req.query('page')
const filters = c.req.queries('filter') // string[]

// Headers
const auth = c.req.header('Authorization')

// Body (JSON)
const body = await c.req.json()

// Body (form data)
const formData = await c.req.formData()

Context Variables (Typed State)

Use c.set() / c.get() for passing data between middleware and handlers.

// In middleware
c.set('userId', 'user-123')
c.set('requestId', crypto.randomUUID())

// In handler
const userId = c.get('userId')  // typed as string

CHECK: Every variable you c.set() is declared in AppEnv.Variables. IF: You set a variable not in the type. THEN: TypeScript error. Add it to AppEnv.Variables first.

Response Helpers

// JSON response
return c.json({ data: items }, 200)

// Text response
return c.text('OK', 200)

// HTML response
return c.html('<h1>Hello</h1>')

// Redirect
return c.redirect('/new-location', 302)

// No content
return c.body(null, 204)

// Custom headers
c.header('X-Custom', 'value')
return c.json({ ok: true })

CHECK: Status code is always the second argument to response helpers. IF: You wrote c.json(data, { status: 200 }). THEN: Wrong. Use c.json(data, 200). Status is a positional arg.


Dependency Injection

GE uses the factory pattern for DI. Dependencies are injected via the app factory, NOT via global imports.

// src/app.ts
import { Hono } from 'hono'
import { AppEnv } from './types/env'

export type AppDeps = {
  db: DrizzleClient
  redis: RedisClient
  config: ServiceConfig
}

export function createApp(deps: AppDeps) {
  const app = new Hono<AppEnv>()

  app.use('*', async (c, next) => {
    c.set('db', deps.db)
    c.set('redis', deps.redis)
    await next()
  })

  // ... routes
  return app
}
// src/index.ts (production)
import { createApp } from './app'
import { createDb } from './lib/db'
import { createRedis } from './lib/redis'

const app = createApp({
  db: createDb(process.env.DATABASE_URL!),
  redis: createRedis(process.env.REDIS_URL!),
  config: loadConfig(),
})

CHECK: Handlers access deps via c.get('db'), NOT via top-level imports. IF: A handler imports db directly from ./lib/db. THEN: Refactor. Direct imports break testability.


Zod Validator Middleware

The @hono/zod-validator provides compile-time AND runtime validation.

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

const createProjectSchema = z.object({
  name: z.string().min(1).max(255),
  clientId: z.string().uuid(),
  description: z.string().optional(),
})

app.post(
  '/api/projects',
  zValidator('json', createProjectSchema),
  async (c) => {
    const data = c.req.valid('json')
    // data is fully typed: { name: string, clientId: string, description?: string }
    return c.json({ project: data }, 201)
  }
)

Validating Different Targets

// Query params
app.get(
  '/api/projects',
  zValidator('query', z.object({
    page: z.coerce.number().int().positive().default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
  })),
  async (c) => {
    const { page, limit } = c.req.valid('query')
    return c.json({ page, limit })
  }
)

// Path params
app.get(
  '/api/projects/:id',
  zValidator('param', z.object({
    id: z.string().uuid(),
  })),
  async (c) => {
    const { id } = c.req.valid('param')
    return c.json({ id })
  }
)

// Headers
app.get(
  '/api/internal',
  zValidator('header', z.object({
    'x-api-key': z.string().min(1),
  })),
  async (c) => {
    return c.json({ ok: true })
  }
)

Custom Validation Error Responses

GE uses a standard validation error hook across all services:

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

function geValidator<T extends z.ZodType>(
  target: 'json' | 'query' | 'param' | 'header' | 'form',
  schema: T
) {
  return zValidator(target, schema, (result, c) => {
    if (!result.success) {
      return c.json({
        error: 'VALIDATION_ERROR',
        issues: result.error.issues,
      }, 422)
    }
  })
}

CHECK: Validation errors return 422, NOT 400. THEN: 422 Unprocessable Entity is the GE standard for validation failures.

CHECK: You access result.error.issues, NOT result.error.errors. IF: Using Zod v4. THEN: The property is .issues. See wiki/docs/stack/hono/pitfalls.md.


RPC Mode (End-to-End Type Safety)

Hono RPC lets frontend code call backend endpoints with full type inference. No codegen, no manual interface files.

Server Side

// src/routes/projects.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { AppEnv } from '../types/env'

export const projectRoutes = new Hono<AppEnv>()
  .get('/', async (c) => {
    return c.json({ projects: [] as Project[] })
  })
  .post(
    '/',
    zValidator('json', createProjectSchema),
    async (c) => {
      const data = c.req.valid('json')
      return c.json({ project: data }, 201)
    }
  )
  .get('/:id', async (c) => {
    const id = c.req.param('id')
    return c.json({ project: { id } })
  })

CHECK: Routes are CHAINED (.get().post().get()) for RPC type inference. IF: You define routes separately (app.get(...) then app.post(...)). THEN: The RPC client loses type information. Chain them.

Client Side

// In frontend or another service
import { hc } from 'hono/client'
import type { projectRoutes } from '../api/routes/projects'

const client = hc<typeof projectRoutes>('http://api.internal:3000/api/projects')

// Fully typed — IDE autocomplete works
const res = await client.index.$get()
const data = await res.json() // typed as { projects: Project[] }

const created = await client.index.$post({
  json: { name: 'New Project', clientId: '...' }
})

CHECK: The RPC client import is type only (no runtime dependency on server). IF: You import the actual route object (not type). THEN: You are bundling server code into the client. Use import type.


OpenAPI Integration

GE uses @hono/zod-openapi for auto-generated API documentation.

import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'
import { AppEnv } from '../types/env'

const app = new OpenAPIHono<AppEnv>()

const getProjectRoute = createRoute({
  method: 'get',
  path: '/api/projects/{id}',
  request: {
    params: z.object({
      id: z.string().uuid().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }),
    }),
  },
  responses: {
    200: {
      content: { 'application/json': { schema: projectResponseSchema } },
      description: 'Project retrieved',
    },
    404: {
      content: { 'application/json': { schema: errorSchema } },
      description: 'Project not found',
    },
  },
})

app.openapi(getProjectRoute, async (c) => {
  const { id } = c.req.valid('param')
  // Handler is fully typed based on the route definition
  return c.json({ project: { id, name: 'Example' } }, 200)
})

// Serve OpenAPI spec
app.doc('/openapi.json', {
  openapi: '3.1.0',
  info: { title: 'GE Project API', version: '1.0.0' },
})

CHECK: OpenAPI route definitions use {id} path syntax (NOT :id). IF: You wrote /projects/:id in the OpenAPI route path. THEN: Use /projects/{id}. OpenAPI spec uses curly braces.


GE API Conventions

URL Structure

All GE APIs follow this pattern:

/api/{version}/{resource}
/api/{version}/{resource}/{id}
/api/{version}/{resource}/{id}/{sub-resource}

Version is optional for internal services. Required for client-facing APIs.

Response Envelope

GE uses a consistent response envelope:

// Success
{ "data": T }

// Success with pagination
{ "data": T[], "meta": { "page": 1, "limit": 20, "total": 142 } }

// Error
{ "error": "ERROR_CODE", "message": "Human-readable message" }

// Validation error
{ "error": "VALIDATION_ERROR", "issues": ZodIssue[] }

CHECK: Every success response wraps data in a data key. IF: You return raw data like c.json(items). THEN: Wrap it: c.json({ data: items }).

HTTP Status Codes

Code Usage
200 Success (GET, PUT, PATCH)
201 Created (POST)
204 No content (DELETE)
400 Bad request (malformed)
401 Unauthorized (no/invalid token)
403 Forbidden (valid token, no permission)
404 Not found
409 Conflict (duplicate)
422 Validation error (Zod)
429 Rate limited
500 Internal server error

Naming Conventions

Element Convention Example
URL paths kebab-case, plural nouns /api/work-packages
Query params camelCase ?clientId=...&pageSize=20
JSON fields camelCase { "workPackageId": "..." }
Error codes SCREAMING_SNAKE VALIDATION_ERROR

Cross-References

READ_ALSO: wiki/docs/stack/hono/index.md READ_ALSO: wiki/docs/stack/hono/middleware.md READ_ALSO: wiki/docs/stack/hono/pitfalls.md READ_ALSO: wiki/docs/stack/hono/checklist.md