Hono JSON API: Build a TypeScript REST API with c.json()

Hono is an ultra-fast web framework that benchmarks at 140,000+ requests per second on Cloudflare Workers and returns JSON with a single call: c.json(data, 200) — one line that sets Content-Type: application/json and serializes any object, making it ideal for edge-deployed JSON APIs. Hono runs on Cloudflare Workers, Deno, Bun, Node.js, and AWS Lambda from the same codebase. On the request side, await c.req.json() parses the body in 2 lines with automatic Content-Type checking — no separate body-parser middleware required, unlike Express. This guide covers Hono route setup, returning JSON responses with c.json(), reading request bodies, input validation with @hono/zod-validator, error handling middleware, CORS, and deploying a complete JSON API to Cloudflare Workers. Bundle size is approximately 12 KB gzipped — smaller than Express (~200 KB) and Fastify (~100 KB). Use Jsonic's JSON formatter to inspect and validate API responses during development.

Need to validate or pretty-print a JSON response from your Hono API? Jsonic's formatter handles it instantly.

Open JSON Formatter

Hono quick start: install, create a route, return JSON

Scaffold a Hono project in under 60 seconds with the official CLI. The npm create hono@latest command prompts for a runtime template and generates a ready-to-run project with TypeScript configured, so you get type-safe route handlers and full editor autocomplete from the first line of code.

# Scaffold — choose cloudflare-workers, bun, deno, or nodejs template
npm create hono@latest my-api
cd my-api
npm install

The entry point is src/index.ts. Replace the default content with a minimal JSON API. The Hono class is the app instance; routes are registered with app.get(), app.post(), etc.; and every handler receives a typed context object c.

import { Hono } from 'hono'

const app = new Hono()

// GET /health → { status: "ok", runtime: "cloudflare-workers" }
app.get('/health', (c) => {
  return c.json({ status: 'ok', runtime: 'cloudflare-workers' })
})

// GET /users/:id → { id: "1", name: "Alice" }
app.get('/users/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ id, name: 'Alice' })
})

export default app

Run locally with wrangler dev (Cloudflare Workers) or bun run dev (Bun). The development server hot-reloads on every save. Three things to note: (1) c.json() is a one-liner — no res.status().json() chain; (2) the export is a default app object with a fetch property — this is the Web Fetch API handler signature that Workers, Deno, and Bun all expect; (3) no framework-specific adapters are needed to switch runtimes — only the scaffold template and build command differ.

Returning JSON with c.json() — status codes, headers, and types

c.json() is the primary way to return JSON in Hono. It accepts up to 3 arguments: the data, the HTTP status code, and optional extra headers. Understanding the correct status code for each HTTP verb is essential for a well-behaved REST API JSON response.

import { Hono } from 'hono'

const app = new Hono()

// 200 OK — default, suitable for GET and successful operations
app.get('/items', (c) => {
  const items = [{ id: 1, name: 'Widget' }, { id: 2, name: 'Gadget' }]
  return c.json(items)                // status 200 implied
})

// 201 Created — use for successful POST that creates a resource
app.post('/items', async (c) => {
  const body = await c.req.json()
  const newItem = { id: 3, ...body }
  return c.json(newItem, 201)         // explicit 201
})

// 204 No Content — use for successful DELETE (no body)
app.delete('/items/:id', (c) => {
  // perform deletion...
  return new Response(null, { status: 204 })
})

// 400 Bad Request — invalid input
app.post('/echo', async (c) => {
  const body = await c.req.json().catch(() => null)
  if (!body) return c.json({ error: 'invalid JSON body' }, 400)
  return c.json(body)
})

// 404 Not Found
app.get('/items/:id', (c) => {
  const id = Number(c.req.param('id'))
  const item = db.find(id)
  if (!item) return c.json({ error: 'item not found' }, 404)
  return c.json(item)
})

// Extra headers — e.g. Cache-Control
app.get('/cached', (c) => {
  return c.json({ data: 'hello' }, 200, {
    'Cache-Control': 'public, max-age=60',
  })
})

TypeScript users can pass a generic to c.json() to enforce the return type at the call site: c.json<Item>(item). When you define routes with const app = new Hono<{ Bindings: Env }>(), the c.env object is also fully typed for Cloudflare bindings like KV, D1, and R2.

The c.json() method is syntactic sugar over new Response(JSON.stringify(data), headers) — it is a pure Web Fetch API response, which is why Hono works on any runtime that implements the Web standard without a compatibility shim. Compare this to Next.js JSON API routes which also use the Web Response class in the App Router.

Reading JSON request bodies with c.req.json()

Hono provides c.req.json() for reading a JSON body — no middleware registration required. The method is async and throws if the body is not valid JSON. Best practice is to wrap it in a try/catch or use the @hono/zod-validator middleware which handles parse errors automatically.

import { Hono } from 'hono'

const app = new Hono()

// Minimal: read body, return it
app.post('/echo', async (c) => {
  const body = await c.req.json()
  return c.json(body, 201)
})

// Safe: handle parse errors
app.post('/safe-echo', async (c) => {
  let body: unknown
  try {
    body = await c.req.json()
  } catch {
    return c.json({ error: 'request body must be valid JSON' }, 400)
  }
  return c.json(body, 201)
})

// TypeScript generic: type the parsed body
type CreateUserInput = { name: string; email: string }

app.post('/users', async (c) => {
  const data = await c.req.json<CreateUserInput>()
  // data.name and data.email are typed — but NOT runtime-validated yet
  const user = await db.users.create(data)
  return c.json(user, 201)
})

Note that TypeScript generics on c.req.json() provide compile-time types only — they do not validate the shape at runtime. A client can still send { name: 123 } and TypeScript will not catch it. Always follow up with runtime validation using @hono/zod-validator (covered in the next section) for any user-supplied input.

Other body parsers available on c.req:

// Raw text body
const text = await c.req.text()

// Form data (multipart or application/x-www-form-urlencoded)
const form = await c.req.formData()
const name = form.get('name')

// ArrayBuffer (for binary uploads)
const buffer = await c.req.arrayBuffer()

// Query parameters (type-safe with zod-validator)
const page = c.req.query('page')        // string | undefined
const { page, limit } = c.req.queries() // Record<string, string[]>

Input validation with @hono/zod-validator

The @hono/zod-validator package provides a middleware that validates the request body, query parameters, or route params against a Zod schema before the route handler runs. If validation fails, it returns a 400 response automatically — the handler is never called. This eliminates 15–20 lines of manual validation code per route and makes the expected shape self-documenting in the code.

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

const app = new Hono()

// Define the schema once
const createUserSchema = z.object({
  name: z.string().min(1).max(100),
  email: z.string().email(),
  age:  z.number().int().min(0).max(150).optional(),
})

// Add zValidator as middleware before the handler
app.post(
  '/users',
  zValidator('json', createUserSchema),
  async (c) => {
    // c.req.valid('json') returns the parsed, type-safe body
    // TypeScript type is inferred from createUserSchema
    const { name, email, age } = c.req.valid('json')

    const user = await db.users.create({ name, email, age })
    return c.json(user, 201)
  }
)

// Validate query parameters — same pattern, different target
const listUsersSchema = z.object({
  page:  z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

app.get(
  '/users',
  zValidator('query', listUsersSchema),
  async (c) => {
    const { page, limit } = c.req.valid('query')
    const users = await db.users.list({ page, limit })
    return c.json({ users, page, limit })
  }
)

The default 400 error body from zValidator is the raw Zod error object. To return a custom error shape, pass a callback as the third argument:

app.post(
  '/users',
  zValidator('json', createUserSchema, (result, c) => {
    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) => {
    const body = c.req.valid('json')
    return c.json(await db.users.create(body), 201)
  }
)

Use Zod schema validation for a deeper dive into Zod's API, including discriminated unions, transforms, and refinements. Validate API response shapes in Jsonic's JSON formatter to verify your Hono API returns exactly what your schema allows.

Error handling middleware and CORS in a Hono JSON API

Hono provides 3 hooks for global error handling: app.onError() for unhandled exceptions, app.notFound() for unmatched routes, and the built-in HTTPException class for intentional HTTP errors. All 3 should be configured before any route definition to ensure every response is a consistent JSON shape.

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

const app = new Hono()

// ── CORS — allow any origin for a public API ──────────────────────────
app.use('*', cors({
  origin: '*',                          // or specify: ['https://myapp.com']
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}))

// ── 404 handler — unmatched routes ───────────────────────────────────
app.notFound((c) => {
  return c.json({ error: 'not found', path: c.req.path }, 404)
})

// ── Global error handler ──────────────────────────────────────────────
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)
})

// ── Routes ────────────────────────────────────────────────────────────
app.get('/users/:id', async (c) => {
  const id = c.req.param('id')
  const user = await db.users.findById(id)

  if (!user) {
    // HTTPException automatically triggers app.onError
    throw new HTTPException(404, { message: `user ${id} not found` })
  }

  return c.json(user)
})

// ── Authentication middleware ─────────────────────────────────────────
app.use('/admin/*', async (c, next) => {
  const token = c.req.header('Authorization')?.replace('Bearer ', '')
  if (!token || !isValidToken(token)) {
    throw new HTTPException(401, { message: 'unauthorized' })
  }
  await next()
})

export default app

The cors middleware is built into Hono's standard library — no separate package installation is needed. It handles preflight OPTIONS requests automatically, sets the correct Access-Control-Allow-* headers, and works identically on all supported runtimes. For production APIs, replace origin: '*' with an allowlist of specific domains.

For structured logging of every request and response, add the built-in logger middleware before route registration: app.use('*', logger()) (import from hono/logger). It logs method, path, status code, and elapsed time to stdout in a human-readable format, which Cloudflare Workers surfaces in the wrangler tail stream.

Hono vs Express vs Fastify for JSON APIs — benchmark comparison

Choosing a framework for a new JSON API comes down to 4 factors: throughput, bundle size, ecosystem maturity, and runtime support. The table below compares all 3 frameworks across every meaningful dimension.

HonoExpressFastify
Req/sec (Bun)~80,000~15,000~60,000
Req/sec (Workers)~140,000+Not supportedNot supported
Bundle size (gzip)~12 KB~200 KB~100 KB
TypeScriptFirst-class@types/expressBuilt-in
JSON bodyc.req.json() built-inexpress.json() middlewareBuilt-in
Validation@hono/zod-validatorexpress-validator / JoiBuilt-in JSON Schema
Edge runtimesWorkers, Deno, Bun, LambdaNode.js onlyNode.js only
EcosystemGrowingLargestLarge
JSON responsec.json(data, 201)res.status(201).json(data)reply.status(201).send(data)

Decision rule: Use Hono for edge-deployed APIs (Cloudflare Workers, Deno Deploy, Bun) or when bundle size is a constraint. Use Express when your team has existing Express middleware or a large Node.js ecosystem dependency. Use Fastify when you need built-in JSON Schema serialization for high-throughput Node.js APIs. If you are starting a new project today and deploying to an edge runtime, Hono is the clear choice — 140,000 req/sec at 12 KB is hard to beat.

Complete CRUD JSON API example with Hono and TypeScript

The following example implements a full CRUD API for a todos resource — 5 endpoints covering GET list, GET by ID, POST create, PUT replace, and DELETE. It uses an in-memory store for brevity but the structure maps directly to any database. This is the pattern used in a real Cloudflare Workers deployment.

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { HTTPException } from 'hono/http-exception'
import { cors } from 'hono/cors'
import { z } from 'zod'

// ── Types & in-memory store ───────────────────────────────────────────
type Todo = { id: number; title: string; done: boolean }
let todos: Todo[] = []
let nextId = 1

// ── Schemas ───────────────────────────────────────────────────────────
const createTodoSchema = z.object({
  title: z.string().min(1).max(200),
  done:  z.boolean().default(false),
})

const updateTodoSchema = z.object({
  title: z.string().min(1).max(200).optional(),
  done:  z.boolean().optional(),
})

// ── App ───────────────────────────────────────────────────────────────
const app = new Hono()

app.use('*', cors())

app.notFound((c) => c.json({ error: 'not found' }, 404))
app.onError((err, c) => {
  if (err instanceof HTTPException) return c.json({ error: err.message }, err.status)
  return c.json({ error: 'internal server error' }, 500)
})

// GET /todos — list all
app.get('/todos', (c) => {
  return c.json(todos)
})

// GET /todos/:id — fetch one
app.get('/todos/:id', (c) => {
  const id = Number(c.req.param('id'))
  const todo = todos.find((t) => t.id === id)
  if (!todo) throw new HTTPException(404, { message: 'todo not found' })
  return c.json(todo)
})

// POST /todos — create
app.post('/todos', zValidator('json', createTodoSchema), (c) => {
  const { title, done } = c.req.valid('json')
  const todo: Todo = { id: nextId++, title, done }
  todos.push(todo)
  return c.json(todo, 201)
})

// PUT /todos/:id — replace
app.put(
  '/todos/:id',
  zValidator('json', createTodoSchema),
  (c) => {
    const id = Number(c.req.param('id'))
    const idx = todos.findIndex((t) => t.id === id)
    if (idx === -1) throw new HTTPException(404, { message: 'todo not found' })
    const { title, done } = c.req.valid('json')
    todos[idx] = { id, title, done }
    return c.json(todos[idx])
  }
)

// PATCH /todos/:id — partial update
app.patch(
  '/todos/:id',
  zValidator('json', updateTodoSchema),
  (c) => {
    const id = Number(c.req.param('id'))
    const idx = todos.findIndex((t) => t.id === id)
    if (idx === -1) throw new HTTPException(404, { message: 'todo not found' })
    todos[idx] = { ...todos[idx], ...c.req.valid('json') }
    return c.json(todos[idx])
  }
)

// DELETE /todos/:id — remove
app.delete('/todos/:id', (c) => {
  const id = Number(c.req.param('id'))
  const idx = todos.findIndex((t) => t.id === id)
  if (idx === -1) throw new HTTPException(404, { message: 'todo not found' })
  todos.splice(idx, 1)
  return new Response(null, { status: 204 })
})

export default app

Test with wrangler dev and curl:

# Create a todo
curl -X POST http://localhost:8787/todos \
  -H 'Content-Type: application/json' \
  -d '{"title":"Buy milk","done":false}'
# → {"id":1,"title":"Buy milk","done":false}  (201)

# List all
curl http://localhost:8787/todos
# → [{"id":1,"title":"Buy milk","done":false}]

# Update done status
curl -X PATCH http://localhost:8787/todos/1 \
  -H 'Content-Type: application/json' \
  -d '{"done":true}'
# → {"id":1,"title":"Buy milk","done":true}

# Delete
curl -X DELETE http://localhost:8787/todos/1
# → (empty body, 204)

This same code deploys to Cloudflare Workers with wrangler deploy, runs on Bun with bun run src/index.ts, and runs on Node.js with the @hono/node-server adapter — all without changing the route logic. See REST API JSON response best practices for guidance on status codes and response envelope design.

Deploy a Hono JSON API to Cloudflare Workers in 4 steps

Cloudflare Workers is the most common deployment target for Hono APIs — they share design philosophies (Web Fetch API, edge execution, zero cold-start overhead). A full deploy takes under 5 minutes from a fresh project and results in a globally distributed API with 30+ edge locations, free TLS, and a workers.dev subdomain.

# Step 1 — Scaffold with the cloudflare-workers template
npm create hono@latest my-api
# Select: cloudflare-workers
cd my-api && npm install

# Step 2 — Implement your API in src/index.ts
# (see the CRUD example above)

# Step 3 — Test locally on the actual Workers runtime
npx wrangler dev
# Listening on http://localhost:8787

# Step 4 — Deploy to the Cloudflare edge network
npx wrangler deploy
# Deployed to https://my-api.<your-subdomain>.workers.dev (< 30 seconds)

The wrangler.toml generated by the scaffold controls the Worker name, compatibility date, and bindings:

# wrangler.toml — key configuration options
name            = "my-api"
main            = "src/index.ts"
compatibility_date = "2024-11-01"

# KV namespace binding (key-value store)
[[kv_namespaces]]
binding  = "MY_KV"
id       = "abc123..."

# D1 database binding (SQLite-compatible)
[[d1_databases]]
binding  = "DB"
database_name = "my-db"
database_id   = "xyz456..."

# Environment variables (non-secret)
[vars]
API_VERSION = "v1"

Access bindings inside Hono handlers via c.env: c.env.MY_KV, c.env.DB, c.env.API_VERSION. For secrets (API keys, database passwords), use wrangler secret put SECRET_NAME — never put secrets in wrangler.toml. Secrets are available via c.env at runtime but are never visible in the dashboard. Compare the Workers approach to Next.js JSON API routes which use process.env for environment variables.

Frequently asked questions

How do I return a JSON response in Hono?

Return JSON in Hono with c.json(data, statusCode). The method accepts any serializable value as the first argument and an optional HTTP status code as the second (defaults to 200 if omitted). It automatically sets the Content-Type header to application/json and serializes the object with JSON.stringify. For GET responses use c.json(data). For POST responses that create a resource, use c.json(newResource, 201) to comply with HTTP semantics. For errors, return c.json({ error: 'message' }, 400) or c.json({ error: 'not found' }, 404). Unlike Express where you chain res.status(201).json(data), Hono's c.json() accepts the status code as a second argument — a single call. The response is a standard Web Response object, so Hono works identically on Cloudflare Workers, Bun, Deno, and Node.js. Use Jsonic's JSON formatter to inspect the serialized output during development.

How do I read a JSON request body in Hono?

Read a JSON request body in Hono with await c.req.json(). Declare the handler as async, then call const body = await c.req.json(). Hono checks the Content-Type header automatically and parses the body as JSON. No body-parser middleware is required — unlike Express which needs express.json(). If the Content-Type is not application/json or the body is not valid JSON, c.req.json() throws an error. Wrap it in try/catch or use the @hono/zod-validator middleware which handles parse errors automatically. For type safety in TypeScript, use the generic: await c.req.json<MyType>() to get a typed result — though note this is compile-time only. Always follow with runtime validation for user-supplied input.

How do I validate JSON input in a Hono route with Zod?

Validate JSON input in Hono using @hono/zod-validator. Install with npm install @hono/zod-validator zod. Define a Zod schema, then add zValidator('json', schema) as a middleware argument before your route handler. If the incoming JSON body does not match the schema, zValidator automatically returns a 400 response — the handler never runs. Inside the validated handler, retrieve the parsed and type-safe body with c.req.valid('json'), which returns the Zod-inferred type with full TypeScript autocomplete. You can also validate query parameters with zValidator('query', schema) or route path parameters with zValidator('param', schema). Pass a custom callback as the third argument to zValidator to control the 400 error response shape. See Zod schema validation for a deeper look at building complex schemas.

How does Hono compare to Express and Fastify for JSON APIs?

Hono outperforms Express and Fastify in benchmarks and ships a significantly smaller bundle, but runs on edge runtimes that Express and Fastify do not support. In throughput benchmarks on Bun: Hono reaches roughly 80,000 req/sec, Fastify roughly 60,000 req/sec, and Express roughly 15,000 req/sec. On Cloudflare Workers, Hono reaches 140,000+ req/sec — Express and Fastify do not run on Workers at all. Bundle size: Hono is approximately 12 KB gzipped, vs. roughly 100 KB for Fastify and roughly 200 KB for Express. For JSON APIs specifically, Hono's c.json() is more concise than Express's res.status().json() chain. Express has by far the largest ecosystem. Fastify has built-in JSON Schema serialization that compiles serializers for fast response generation at scale. Hono is the best choice for edge-deployed or multi-runtime JSON APIs. See the comparison table above for all dimensions. Compare also to Express JSON API for the Express equivalent patterns.

How do I deploy a Hono JSON API to Cloudflare Workers?

Deploy a Hono JSON API to Cloudflare Workers in 4 steps. Step 1: scaffold with npm create hono@latest my-api, select the cloudflare-workers template, and cd into the directory. Step 2: implement your routes in src/index.ts using app.get(), app.post(), and c.json() — Hono exports a default fetch handler that Cloudflare Workers expects. Step 3: run wrangler dev to test locally on the Workers runtime with live reload. Step 4: run wrangler deploy to push to the Cloudflare edge network and get a workers.dev URL in under 30 seconds. For secrets, use wrangler secret put MY_SECRET and access via c.env.MY_SECRET. KV, D1, and R2 bindings are configured in wrangler.toml and typed via the Bindings generic on the Hono constructor.

How do I handle JSON errors in Hono middleware?

Handle JSON errors in Hono with app.onError() and app.notFound(). Register a global error handler: app.onError((err, c) => c.json({ error: err.message }, 500)) — this catches any unhandled exception thrown in a route or middleware and returns a structured JSON error. For 404 routes, use app.notFound((c) => c.json({ error: 'not found' }, 404)). For intentional HTTP errors, throw an HTTPException from hono/http-exception: throw new HTTPException(400, { message: 'invalid input' }). Check err instanceof HTTPException in onError to return the correct status code from err.status. For validation errors thrown by @hono/zod-validator, the middleware returns a 400 response automatically before the handler runs. Wrap database calls and external API calls in try/catch inside handlers and return c.json({ error: '...' }, 500) explicitly to avoid leaking stack traces to clients.

Ready to build your Hono JSON API?

Use Jsonic's JSON Formatter to validate and pretty-print responses from your Hono API during development. You can also diff two JSON responses to compare before/after changes when refactoring routes.

Open JSON Formatter