Web Engineering ·

Building Type-Safe APIs with Hono and TypeScript: Routing, Validation, and Middleware on the Edge

How to build production-grade APIs using Hono with TypeScript. Covers type-safe routing, Zod-based validation middleware, error handling patterns, and deployment to Cloudflare Workers.

Building Type-Safe APIs with Hono and TypeScript: Routing, Validation, and Middleware on the Edge

Express has a typing problem that most teams learn to live with. You define a route, write a handler, and TypeScript mostly gets out of the way. The request body is any, the params are string | undefined, and the only thing standing between you and a runtime error is discipline and unit tests. Hono is a different approach: types flow from your route definitions into your handlers, validation failures are caught before your business logic runs, and the whole thing compiles to less than 20KB for Cloudflare Workers.

This article covers how to build a production-grade API with Hono and TypeScript. The patterns here assume you already know TypeScript and have shipped at least one API before. The focus is on the parts that trip up teams moving from Express or Fastify: route typing, middleware composition, validation strategy, and the deployment model for edge runtimes.

Why Hono

Hono is not the only option for edge-native APIs, but it has the clearest approach to type safety among the current alternatives. The comparison matters because the tradeoffs are real:

FrameworkRuntime targetsRequest typingBundle sizeValidation built-in
HonoCF Workers, Deno, Bun, NodeFull type inference from routes~12KBVia middleware (zod-validator)
Itty RouterCF Workers, anyMinimal, mostly manual~1KBNo
ElysiaBun-firstFull type inference~30KBYes (built-in)
ExpressNode onlyWeak (req.body: any)LargeNo
FastifyNode onlyGood with schemasMediumVia JSON Schema

Hono’s advantage is the combination: small bundle, multi-runtime, and genuine type inference from the route definition down to the handler. Elysia is worth watching but the Bun dependency narrows its deployment targets. For Cloudflare Workers specifically, Hono is the most mature option with the widest community.

Project Setup

// package.json dependencies
// "hono": "^4.x.x"
// "@hono/zod-validator": "^0.x.x"
// "zod": "^3.x.x"
// "wrangler": "^3.x.x" (for CF Workers deployment)

// src/index.ts
import { Hono } from "hono"
import { cors } from "hono/cors"
import { logger } from "hono/logger"
import { prettyJSON } from "hono/pretty-json"
import { userRouter } from "./routes/users"
import { orderRouter } from "./routes/orders"

type Bindings = {
  DB: D1Database
  KV: KVNamespace
  API_KEY: string
}

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

app.use("*", logger())
app.use("*", cors({ origin: ["https://app.example.com"] }))
app.use("*", prettyJSON())

app.route("/users", userRouter)
app.route("/orders", orderRouter)

export default app

The Bindings type is where Cloudflare Workers-specific resources land. When you access c.env.DB in a handler, TypeScript knows it’s a D1Database, not unknown. This is the first place teams using Express patterns get tripped up: in Workers, the environment is not process.env. It is typed bindings passed to the fetch handler.

Type-Safe Routing

Hono’s type inference works through a chained builder. Each route you define accumulates into the app’s type signature, which is then used for RPC-style client generation if you want it.

// src/routes/users.ts
import { Hono } from "hono"
import { zValidator } from "@hono/zod-validator"
import { z } from "zod"

type Bindings = {
  DB: D1Database
}

type Variables = {
  userId: string
  role: "admin" | "member"
}

const userRouter = new Hono<{ Bindings: Bindings; Variables: Variables }>()

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
  role: z.enum(["admin", "member"]).default("member"),
})

const UserParamsSchema = z.object({
  id: z.string().uuid(),
})

userRouter.post(
  "/",
  zValidator("json", CreateUserSchema),
  async (c) => {
    // c.req.valid("json") is fully typed as CreateUserSchema output
    const { email, name, role } = c.req.valid("json")

    const stmt = c.env.DB.prepare(
      "INSERT INTO users (email, name, role) VALUES (?, ?, ?) RETURNING id"
    )
    const result = await stmt.bind(email, name, role).first<{ id: string }>()

    if (!result) {
      return c.json({ error: "Failed to create user" }, 500)
    }

    return c.json({ id: result.id, email, name, role }, 201)
  }
)

userRouter.get(
  "/:id",
  zValidator("param", UserParamsSchema),
  async (c) => {
    const { id } = c.req.valid("param")

    const user = await c.env.DB.prepare(
      "SELECT id, email, name, role FROM users WHERE id = ?"
    )
      .bind(id)
      .first<{ id: string; email: string; name: string; role: string }>()

    if (!user) {
      return c.json({ error: "User not found" }, 404)
    }

    return c.json(user)
  }
)

export { userRouter }

The zValidator middleware validates the incoming data and, if it passes, makes the result available via c.req.valid() with full type inference. If validation fails, Hono returns a 400 with the Zod error before your handler runs. You can customize this behavior by passing a callback as the third argument to zValidator:

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,
        })),
      },
      422
    )
  }
})

This gives you control over the error shape without any wrapper boilerplate.

Middleware Chains and Context Variables

The Variables type in the Hono generic is how you pass typed values through middleware without casting. The pattern is: define variables in the type, set them in middleware, read them in handlers.

// src/middleware/auth.ts
import { createMiddleware } from "hono/factory"
import { HTTPException } from "hono/http-exception"

type AuthVariables = {
  userId: string
  role: "admin" | "member"
}

export const authMiddleware = createMiddleware<{
  Bindings: { API_KEY: string }
  Variables: AuthVariables
}>(async (c, next) => {
  const authorization = c.req.header("Authorization")

  if (!authorization?.startsWith("Bearer ")) {
    throw new HTTPException(401, { message: "Missing or invalid token" })
  }

  const token = authorization.slice(7)

  // In a real implementation, verify the JWT here
  const payload = await verifyJwt(token, c.env.API_KEY)

  if (!payload) {
    throw new HTTPException(401, { message: "Invalid token" })
  }

  c.set("userId", payload.sub)
  c.set("role", payload.role)

  await next()
})

async function verifyJwt(
  token: string,
  secret: string
): Promise<{ sub: string; role: "admin" | "member" } | null> {
  try {
    // Web Crypto API works in all edge runtimes
    const key = await crypto.subtle.importKey(
      "raw",
      new TextEncoder().encode(secret),
      { name: "HMAC", hash: "SHA-256" },
      false,
      ["verify"]
    )

    const [headerB64, payloadB64, sigB64] = token.split(".")
    const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`)
    const sig = Uint8Array.from(atob(sigB64.replace(/-/g, "+").replace(/_/g, "/")), (c) =>
      c.charCodeAt(0)
    )

    const valid = await crypto.subtle.verify("HMAC", key, sig, data)
    if (!valid) return null

    const payload = JSON.parse(atob(payloadB64))
    return payload
  } catch {
    return null
  }
}
// src/routes/orders.ts
import { Hono } from "hono"
import { authMiddleware } from "../middleware/auth"

type Bindings = { DB: D1Database; API_KEY: string }
type Variables = { userId: string; role: "admin" | "member" }

const orderRouter = new Hono<{ Bindings: Bindings; Variables: Variables }>()

orderRouter.use("*", authMiddleware)

orderRouter.get("/", async (c) => {
  // c.get("userId") is typed as string, not string | undefined
  const userId = c.get("userId")
  const role = c.get("role")

  const query =
    role === "admin"
      ? "SELECT * FROM orders"
      : "SELECT * FROM orders WHERE user_id = ?"

  const stmt =
    role === "admin"
      ? c.env.DB.prepare(query)
      : c.env.DB.prepare(query).bind(userId)

  const orders = await stmt.all()
  return c.json(orders.results)
})

export { orderRouter }

The key thing here is that c.get("userId") returns string, not string | undefined, because the Variables type tells TypeScript that these values are present. If you forget to set them in middleware and access them in a handler, you get a type error at compile time, not a runtime crash.

Error Handling

Hono provides HTTPException for predictable error responses, and a global error handler for everything else.

// src/index.ts (extended)
import { HTTPException } from "hono/http-exception"

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse()
  }

  // Log the actual error for observability
  console.error({
    error: err.message,
    stack: err.stack,
    path: c.req.path,
    method: c.req.method,
  })

  return c.json({ error: "Internal server error" }, 500)
})

app.notFound((c) => {
  return c.json({ error: `Route ${c.req.method} ${c.req.path} not found` }, 404)
})

For domain-level errors, keep them separate from HTTP errors:

// src/lib/errors.ts
export class NotFoundError extends Error {
  constructor(resource: string, id: string) {
    super(`${resource} with id ${id} not found`)
    this.name = "NotFoundError"
  }
}

export class ConflictError extends Error {
  constructor(message: string) {
    super(message)
    this.name = "ConflictError"
  }
}

// src/index.ts
import { NotFoundError, ConflictError } from "./lib/errors"

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return err.getResponse()
  }

  if (err instanceof NotFoundError) {
    return c.json({ error: err.message }, 404)
  }

  if (err instanceof ConflictError) {
    return c.json({ error: err.message }, 409)
  }

  console.error({ error: err.message, stack: err.stack })
  return c.json({ error: "Internal server error" }, 500)
})

This keeps your route handlers clean: throw the domain error, let the global handler translate it to HTTP.

Testing

Hono ships with a test client that works without spinning up an HTTP server. This matters for edge runtimes where you cannot use supertest directly.

// src/routes/users.test.ts
import { describe, it, expect, beforeEach } from "vitest"
import { app } from "../index"

// Mock the D1 database binding
const mockDb = {
  prepare: (sql: string) => ({
    bind: (...args: unknown[]) => ({
      first: async () => ({ id: "123e4567-e89b-12d3-a456-426614174000" }),
      all: async () => ({ results: [] }),
      run: async () => ({ success: true }),
    }),
  }),
}

describe("POST /users", () => {
  it("creates a user with valid input", async () => {
    const res = await app.request(
      "/users",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          email: "alice@example.com",
          name: "Alice",
          role: "member",
        }),
      },
      { DB: mockDb, API_KEY: "test-key" } // env bindings
    )

    expect(res.status).toBe(201)
    const body = await res.json()
    expect(body.email).toBe("alice@example.com")
  })

  it("rejects invalid email", async () => {
    const res = await app.request(
      "/users",
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: "not-an-email", name: "Alice" }),
      },
      { DB: mockDb, API_KEY: "test-key" }
    )

    expect(res.status).toBe(422)
    const body = await res.json()
    expect(body.issues).toHaveLength(1)
    expect(body.issues[0].field).toBe("email")
  })

  it("returns 401 for missing auth on protected routes", async () => {
    const res = await app.request("/orders", { method: "GET" }, {
      DB: mockDb,
      API_KEY: "test-key",
    })

    expect(res.status).toBe(401)
  })
})

The third argument to app.request() is the environment bindings. This is cleaner than Express testing patterns because there is no network involved, and the bindings are typed from your Bindings type.

Deployment to Cloudflare Workers

The wrangler.toml configuration is where your bindings get declared:

name = "my-api"
main = "src/index.ts"
compatibility_date = "2024-09-23"
compatibility_flags = ["nodejs_compat"]

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

[[kv_namespaces]]
binding = "KV"
id = "your-kv-namespace-id"

[vars]
# Non-secret env vars go here

# Secrets set via: wrangler secret put API_KEY

One thing to watch: Hono’s logger() middleware uses console.log, which in Workers goes to the Workers Logs dashboard, not stdout. This works fine in development but production observability requires binding to something like Workers Analytics Engine or a third-party log drain. The Workers runtime does not support streaming log aggregation out of the box.

For local development, wrangler dev provides a local Workers runtime with D1 and KV emulation. Your TypeScript types for Bindings and the wrangler.toml declarations need to stay in sync manually. Wrangler can generate a worker-configuration.d.ts file via wrangler types that does this for you, though you still need to keep your app’s Bindings type aligned.

Production Considerations

Cold starts. Cloudflare Workers do not have traditional cold starts in the way Lambda does. The isolate model means your code is always pre-initialized per PoP. What you do pay for is the first request to a new isolate after a period of inactivity, which is typically under 5ms for Hono apps. This is generally not a concern at the application layer.

Request limits. The free tier has a 10ms CPU time limit per request. Paid plans allow up to 30 seconds. If your API does any non-trivial computation (heavy JSON transformation, synchronous crypto), profile CPU time via wrangler dev --log-level debug before deploying to production. D1 queries are async and do not count against CPU time in the same way.

Schema validation cost. Zod is not free. A complex schema with many refinements on a hot path adds measurable overhead. For performance-critical routes, consider using Zod only for external-facing endpoints and simpler type assertions for internal service calls. Alternatively, Valibot has a smaller runtime footprint if bundle size is a hard constraint.

Secrets. Use wrangler secret put for sensitive values. They appear as c.env.VARIABLE_NAME in your code with the type you declared in Bindings, but they are never stored in wrangler.toml or version control.

RPC Clients

One underused feature of Hono is typed RPC clients. When you export your app’s type, a client in another package can call your API with full request/response typing:

// packages/api-client/src/index.ts
import { hc } from "hono/client"
import type { AppType } from "../../api/src/index"

const client = hc<AppType>("https://api.example.com")

// Fully typed: knows the shape of the request body and response
const res = await client.users.$post({
  json: { email: "alice@example.com", name: "Alice" },
})

if (res.ok) {
  const user = await res.json() // typed from the route definition
  console.log(user.id) // string, not any
}

This requires your API and client to share the type, which works cleanly in a monorepo. The tradeoff is that it couples the client and server deployment cycles more tightly than an OpenAPI contract would. For internal service-to-service calls in a monorepo, the coupling is fine and the type safety is worth it. For public APIs where you want versioning and a stable schema, generate OpenAPI docs from Zod schemas via @hono/zod-openapi instead.

What Hono Does Not Solve

Hono gives you types at the routing layer. It does not give you types for your database layer, your external service calls, or your domain model. Those still require discipline. The pattern of validating at the edge with Zod and then passing untyped data to your DB layer is a common mistake: you validate the request but do not validate the response from D1, so first<{ id: string }>() is a cast, not a guarantee.

For a complete type-safe stack, pair Hono with a schema-first DB library like Drizzle ORM, which generates TypeScript types from your SQL schema. Then your request types, domain types, and response types all derive from declared schemas rather than being asserted.

The combination of Hono for routing, Zod for validation, and Drizzle for DB access gives you end-to-end type safety with no any in the happy path. That is achievable today and it is a meaningful reduction in the class of bugs that reach production.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.