JSON in Hono: c.json(), Request Parsing, and Validation

Last updated:

Hono uses c.json(data, status?) to send JSON responses — it automatically sets Content-Type: application/json and calls JSON.stringify. To parse an incoming JSON body, call await c.req.json(). Hono runs identically on Cloudflare Workers, Bun, Deno, Node.js, and AWS Lambda, so the same JSON code works across all 5 runtimes. For validated input, the @hono/zod-validator middleware rejects malformed requests before your handler runs. This guide covers 5 topics: sending JSON responses with c.json(), parsing request bodies, input validation with Zod, JSON error handling, and streaming JSON responses.

Validate your Hono JSON responses

Paste JSON from your Hono handler into Jsonic's formatter to inspect and validate the structure.

Open JSON Formatter

Send JSON Responses with c.json()

Bottom line: c.json(data) is the canonical way to return JSON in Hono. It wraps the value in a Response with Content-Type: application/json set automatically — no manual header needed. The optional second argument sets the HTTP status code: c.json(data, 201) for Created, c.json({ error: "Not found" }, 404) for errors.

Hono supports TypeScript generics on c.json() through its typed route system. When you define a route with app.get<"/users/:id", TypedResponse> and use Hono's typed helpers, the return type of c.json() narrows to match your declared response schema — useful for generating OpenAPI specs or sharing types between client and server with hc (Hono Client). For cross-runtime compatibility, always use c.json() rather than returning new Response(JSON.stringify(data), { headers: ... }) directly — the latter bypasses Hono's response pipeline.

import { Hono } from "hono"

const app = new Hono()

// Basic JSON response — status 200 by default
app.get("/users", (c) => {
  const users = [{ id: 1, name: "Alice" }, { id: 2, name: "Bob" }]
  return c.json(users)
})

// Custom status code as second argument
app.post("/users", async (c) => {
  const body = await c.req.json()
  const newUser = { id: Date.now(), ...body }
  return c.json(newUser, 201)  // 201 Created
})

// Error response with status code
app.get("/users/:id", (c) => {
  const id = Number(c.req.param("id"))
  if (id !== 1) {
    return c.json({ error: "User not found" }, 404)
  }
  return c.json({ id: 1, name: "Alice" })
})

// Typed responses with Hono's type system
import type { TypedResponse } from "hono"

type User = { id: number; name: string }

app.get("/typed", (c): TypedResponse<User> => {
  return c.json({ id: 1, name: "Alice" })
})

export default app

Parse JSON Request Bodies with c.req.json()

Bottom line: await c.req.json() reads the request body and parses it as JSON. It throws a TypeError if the Content-Type header is not application/json, and a SyntaxError if the body is malformed. Always wrap in try/catch to return a proper 400 response instead of an unhandled 500 error.

Under the hood, c.req.json() delegates to the native Request.json() method from the WHATWG Fetch API — the same method available in browsers and Cloudflare Workers. This means the implementation is zero-overhead on edge runtimes: no buffering or extra parsing layer. For large request bodies, be aware that c.req.json() reads the entire body into memory before parsing. If you expect very large payloads (above a few MB), consider streaming with c.req.body and a streaming JSON parser. For normal API payloads, use c.req.json() with a try/catch and validate the shape afterward.

import { Hono } from "hono"

const app = new Hono()

// Basic POST body parsing
app.post("/items", async (c) => {
  try {
    const body = await c.req.json<{ name: string; price: number }>()
    if (!body.name || typeof body.price !== "number") {
      return c.json({ error: "name (string) and price (number) are required" }, 400)
    }
    return c.json({ id: Date.now(), ...body }, 201)
  } catch (err) {
    // TypeError: Content-Type not application/json
    // SyntaxError: body is not valid JSON
    return c.json({ error: "Invalid JSON body" }, 400)
  }
})

// PUT request — partial update body
app.put("/items/:id", async (c) => {
  const id = c.req.param("id")
  let body: Partial<{ name: string; price: number }>

  try {
    body = await c.req.json()
  } catch {
    return c.json({ error: "Request body must be valid JSON" }, 400)
  }

  // Apply the partial update
  return c.json({ id, ...body })
})

// Reading both JSON body and query params
app.post("/search", async (c) => {
  const page = Number(c.req.query("page") ?? "1")
  const body = await c.req.json<{ filters: string[] }>()
  return c.json({ page, filters: body.filters, results: [] })
})

export default app

Validate JSON Input with @hono/zod-validator

Bottom line: install @hono/zod-validator and zod, then add validator("json", schema) as middleware before your handler. If the request body fails validation, Hono returns 400 automatically. If it passes, c.req.valid("json") gives you the fully-typed, parsed body — no manual type assertion needed.

The validator() middleware from @hono/zod-validator runs synchronously in Hono's middleware chain before the route handler. It calls schema.safeParse() on the parsed body and short-circuits to a 400 response if validation fails — the handler never executes. This pattern keeps route handlers clean: no try/catch, no manual shape checks. You can validate multiple targets in one route: validator("json", bodySchema) for the body and validator("query", querySchema) for query strings. Both results are accessible via c.req.valid("json") and c.req.valid("query") respectively.

// Install: npm install @hono/zod-validator zod
import { Hono } from "hono"
import { validator } from "@hono/zod-validator"
import { z } from "zod"

const app = new Hono()

// Define your schema
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 validator middleware before the handler
app.post(
  "/users",
  validator("json", CreateUserSchema),
  (c) => {
    // c.req.valid("json") is fully typed as z.infer<typeof CreateUserSchema>
    const { name, email, age } = c.req.valid("json")
    return c.json({ id: Date.now(), name, email, age }, 201)
  }
)

// Custom error response — override the default 400 behavior
app.post(
  "/products",
  validator("json", z.object({ title: z.string(), price: z.number().positive() }), (result, c) => {
    if (!result.success) {
      return c.json(
        { error: "Validation failed", issues: result.error.issues },
        422  // Unprocessable Entity
      )
    }
  }),
  (c) => {
    const { title, price } = c.req.valid("json")
    return c.json({ id: Date.now(), title, price }, 201)
  }
)

// Validate both JSON body and query string
const PaginationSchema = z.object({
  page: z.coerce.number().int().min(1).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
})

app.post(
  "/search",
  validator("query", PaginationSchema),
  validator("json", z.object({ query: z.string() })),
  (c) => {
    const { page, limit } = c.req.valid("query")
    const { query } = c.req.valid("json")
    return c.json({ page, limit, query, results: [] })
  }
)

export default app

Handle JSON Errors and Status Codes

Bottom line: use HTTPException from "hono/http-exception" to throw HTTP errors from anywhere in your code — handlers, middleware, or helper functions. Register a global app.onError() handler to catch them and return a consistent JSON error shape. For RFC 7807 Problem JSON, include type, title, status, detail, and optionally instance fields.

Hono calls app.onError() whenever a handler throws an unhandled error, including HTTPException. Without a custom error handler, Hono returns a plain-text 500 response. The HTTPException class accepts a status code and an optional message — you can also pass a custom Response object via the res option for full control. RFC 7807 Problem JSON is a standard format for API error responses: Content-Type: application/problem+json, with structured fields that help API consumers programmatically handle different error types without string-parsing error messages.

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

const app = new Hono()

// Global error handler — catches all unhandled errors
app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return c.json(
      { error: err.message, status: err.status },
      err.status
    )
  }
  console.error(err)
  return c.json({ error: "Internal server error" }, 500)
})

// Throw HTTPException from a handler
app.get("/users/:id", async (c) => {
  const id = Number(c.req.param("id"))
  const user = await findUser(id)

  if (!user) {
    throw new HTTPException(404, { message: `User ${id} not found` })
  }

  return c.json(user)
})

// RFC 7807 Problem JSON error handler
app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return c.json(
      {
        type: `https://jsonic.io/errors/${err.status}`,
        title: httpStatusTitle(err.status),
        status: err.status,
        detail: err.message,
        instance: c.req.path,
      },
      err.status,
      { "Content-Type": "application/problem+json" }
    )
  }
  return c.json({ error: "Unexpected error" }, 500)
})

// Custom 404 for unmatched routes
app.notFound((c) =>
  c.json(
    { type: "https://jsonic.io/errors/404", title: "Not Found", status: 404, instance: c.req.path },
    404
  )
)

function httpStatusTitle(status: number): string {
  const titles: Record<number, string> = {
    400: "Bad Request", 401: "Unauthorized", 403: "Forbidden",
    404: "Not Found", 422: "Unprocessable Entity", 500: "Internal Server Error",
  }
  return titles[status] ?? "Error"
}

async function findUser(id: number) {
  return id === 1 ? { id: 1, name: "Alice" } : null
}

export default app

Stream JSON Responses in Hono

Bottom line: use streamSSE() from "hono/streaming" to stream JSON as Server-Sent Events — the standard protocol for AI/LLM token streaming. Use stream() for raw chunked JSON or NDJSON (newline-delimited JSON). Both helpers work across all Hono runtimes including Cloudflare Workers.

streamSSE() manages the SSE protocol details for you: it sets Content-Type: text/event-stream, Cache-Control: no-cache, and Connection: keep-alive headers automatically. Call await stream.writeSSE({ data: JSON.stringify(chunk) }) to send each event. The client receives events via the browser's native EventSource API or any SSE client library. For NDJSON streaming — where each line is a complete JSON object — use stream() and write JSON.stringify(obj) + "\n" for each record. This format is commonly used for bulk data exports and LLM token streams that don't require SSE's reconnection semantics.

import { Hono } from "hono"
import { stream, streamSSE } from "hono/streaming"

const app = new Hono()

// SSE — stream JSON events (AI token streaming, live updates)
app.get("/stream/events", (c) => {
  return streamSSE(c, async (stream) => {
    const items = [
      { id: 1, token: "Hello" },
      { id: 2, token: " world" },
      { id: 3, token: "!" },
    ]

    for (const item of items) {
      await stream.writeSSE({
        data: JSON.stringify(item),
        event: "token",        // optional event name
        id: String(item.id),   // optional event ID for reconnection
      })
      // Simulate async work (e.g. LLM token arrival)
      await new Promise((r) => setTimeout(r, 100))
    }

    // Send a final "done" event
    await stream.writeSSE({ data: JSON.stringify({ done: true }), event: "done" })
  })
})

// Raw stream — NDJSON (one JSON object per line)
app.get("/stream/ndjson", (c) => {
  return stream(c, async (stream) => {
    const records = Array.from({ length: 5 }, (_, i) => ({
      id: i + 1,
      value: Math.random(),
      ts: new Date().toISOString(),
    }))

    for (const record of records) {
      await stream.write(JSON.stringify(record) + "\n")
      await new Promise((r) => setTimeout(r, 50))
    }
  })
})

// Client-side: consume SSE stream
// const source = new EventSource("/stream/events")
// source.addEventListener("token", (e) => {
//   const data = JSON.parse(e.data)
//   console.log(data.token)
// })
// source.addEventListener("done", () => source.close())

export default app

Deploy Hono JSON APIs Across Runtimes

Bottom line: your Hono app code — routes, middleware, c.json(), c.req.json() — is identical across all runtimes. Only the entry-point import changes to use the runtime-specific adapter. This means you can write one codebase and deploy to Cloudflare Workers, Bun, Node.js, Deno, or Vercel Edge with a one-line change.

Hono achieves runtime portability by building on the WHATWG Fetch API (Request, Response) which is implemented natively by all modern runtimes and Cloudflare Workers. The app instance is just a function that takes a Request and returns a Promise<Response>. Each runtime adapter wraps this function in the platform's native server interface — Bun.serve() for Bun, http.createServer() for Node.js, the fetch export for Cloudflare Workers. The JSON-handling APIs (c.json(), c.req.json()) and all middleware are implemented once in Hono core with no runtime-specific branches.

// app.ts — shared application code (same on all runtimes)
import { Hono } from "hono"
import { validator } from "@hono/zod-validator"
import { z } from "zod"

export const app = new Hono()

app.get("/api/health", (c) => c.json({ ok: true }))
app.post(
  "/api/users",
  validator("json", z.object({ name: z.string() })),
  (c) => {
    const { name } = c.req.valid("json")
    return c.json({ id: Date.now(), name }, 201)
  }
)

// ── Cloudflare Workers entry (wrangler.toml: main = "worker.ts") ──
// worker.ts
import { app } from "./app"
export default app   // Workers calls app.fetch() directly

// ── Bun entry ──
// server.ts
import { app } from "./app"
Bun.serve({ port: 3000, fetch: app.fetch })

// ── Node.js entry (npm install @hono/node-server) ──
// server.ts
import { serve } from "@hono/node-server"
import { app } from "./app"
serve({ fetch: app.fetch, port: 3000 })

// ── Deno entry ──
// server.ts
import { app } from "./app"
Deno.serve({ port: 3000 }, app.fetch)

// ── Vercel Edge (vercel.json: { "functions": { "api/*.ts": { "runtime": "edge" } } }) ──
// api/route.ts
import { app } from "../app"
export const runtime = "edge"
export const GET = app.fetch
export const POST = app.fetch

FAQ

How do I send a JSON response in Hono?

Use return c.json(data) in your route handler. Hono sets Content-Type: application/json and serializes the value automatically. To override the HTTP status, pass it as the second argument: c.json(data, 201) or c.json({ error: "Not found" }, 404).

How do I parse a JSON request body in Hono?

Call await c.req.json() inside your handler. Wrap it in try/catch — it throws if Content-Type is not application/json or if the body is not valid JSON. For validated parsing, use @hono/zod-validator instead and access the typed result via c.req.valid("json").

How do I validate JSON input in Hono?

Install @hono/zod-validator and zod, then add validator("json", schema) as middleware before your route handler. Invalid requests get a 400 response automatically. Access the validated, typed body with c.req.valid("json") — it's fully typed from your Zod schema with no manual type assertions needed.

How do I handle JSON errors in Hono?

Throw new HTTPException(status, { message } (from "hono/http-exception") anywhere in your code, and register a global handler with app.onError((err, c) { ... }). For RFC 7807 Problem JSON, return a c.json() response with type, title, status, and detail fields and set Content-Type: application/problem+json.

Is Hono faster than Express for JSON APIs?

Yes — Hono handles approximately 3–5× more JSON requests per second than Express on Node.js benchmarks, and its ~14 KB bundle is far smaller than Express's ~200 KB, making it practical for Cloudflare Workers. Hono's RegExpRouter compiles all routes at startup and matches in O(1) per request, while Express evaluates middleware layers sequentially. On Bun, the advantage is even larger due to Bun's faster native HTTP server.

How do I use Hono on Cloudflare Workers?

Run npm create hono@latest and select the cloudflare-workers template. Your entry file exports export default app — Workers calls app.fetch(request, env, ctx) automatically. Deploy with wrangler deploy. All JSON APIs (c.json(), c.req.json(), validators) work without modification on Workers.

Further reading and primary sources

  • Hono Documentation — Context#jsonOfficial Hono API reference for c.json(), c.req.json(), and the Context object methods for JSON handling
  • @hono/zod-validatorOfficial Hono middleware package for Zod schema validation of JSON request bodies, query strings, and path params
  • Hono on Cloudflare WorkersOfficial getting-started guide for deploying Hono JSON APIs to Cloudflare Workers with wrangler
  • Hono middleware guideOfficial Hono guide covering built-in middleware, third-party middleware, and writing custom middleware for JSON APIs
  • Hono streamSSE APIOfficial reference for Hono's streaming helpers: streamSSE() for Server-Sent Events and stream() for raw chunked responses