Dev Encyclopedia
ArticlesTools

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

ยฉ 2026 Dev Encyclopedia

Privacy PolicyTermsDisclaimer
  1. Home
  2. /Blog
  3. /Hono.js Tutorial: REST API with Zod, JWT & Cloudflare Workers
backend14 min read

Hono.js Tutorial: REST API with Zod, JWT & Cloudflare Workers

Step-by-step Hono.js tutorial: routing, middleware, Zod validation, JWT auth, and deployment to Cloudflare Workers and Node.js. Working code throughout.

By Dev EncyclopediaPublished June 15, 2026
On this page

On this page

  • Project Setup with create-hono
  • Your First Routes: Params, Queries, and JSON Bodies
  • Middleware: Logging, CORS, and Custom Auth Checks
  • Input Validation with Zod
  • JWT Authentication
  • Organizing a Real API with app.route()
  • Deploying to Cloudflare Workers
  • Deploying to Node.js Instead
  • Hono vs Express: The Honest Comparison
  • Frequently Asked Questions

Hono is a web framework built on the Web Fetch API, which means the same router and middleware run unchanged on Cloudflare Workers, Bun, Deno, Node.js, and AWS Lambda. It's small (around 14KB), has zero dependencies, and ships with first-class TypeScript support including full type inference for route params, query strings, and validated request bodies. Cloudflare uses it internally for parts of its own product surface.

That combination matters in 2026: edge runtimes are now the default target for new APIs, and Hono is the framework that treats "runs everywhere" as a core design goal rather than an afterthought. This guide builds a real REST API from scratch: routing, middleware, Zod validation, JWT authentication, and a deploy to Cloudflare Workers (with a Node.js fallback for when you need it).

๐Ÿ’ก TL;DR

Run npm create hono@latest my-api, pick the cloudflare-workers template, then cd my-api && npm install && npm run dev. The rest of this guide builds a validated, JWT-protected API on top of that starter and deploys it with wrangler deploy.

  1. 1

    Project Setup with create-hono

    Scaffold a new project with the official CLI. It asks which runtime you're targeting and sets up the right config and dependencies for that target automatically.

    bash
    npm create hono@latest my-api

    You'll be prompted to pick a template. For this guide, choose cloudflare-workers. Other options include bun, deno, nodejs, aws-lambda, and vercel, all sharing the same core API.

    • `src/index.ts` - your app's entry point and router.
    • `wrangler.toml` or `wrangler.jsonc` - Cloudflare Workers configuration (covered in Step 7).
    • `package.json` - includes hono and the wrangler CLI as dependencies.
    bash
    cd my-api
    npm install
    npm run dev

    npm run dev starts a local dev server (powered by Wrangler) at http://localhost:8787. The generated starter looks like this:

    typescript โ€” src/index.ts
    import { Hono } from 'hono'
    
    const app = new Hono()
    
    app.get('/', (c) => {
      return c.json({ message: 'Hello Hono!' })
    })
    
    export default app

    c is the context object, and it's the only argument every handler receives. It wraps the incoming request (c.req), gives you response helpers (c.json(), c.text(), c.html()), and on Cloudflare Workers exposes bindings and environment variables through c.env. Almost everything you do in a Hono handler goes through c.

  2. 2

    Your First Routes: Params, Queries, and JSON Bodies

    Hono's routing API will feel immediately familiar if you've used Express, but every handler runs through the same (c) => ... shape. Here's a small set of routes covering the three most common inputs: a path parameter, a query string, and a JSON body.

    typescript โ€” src/index.ts
    import { Hono } from 'hono'
    
    const app = new Hono()
    
    // GET /users/123 -> path parameter
    app.get('/users/:id', (c) => {
      const id = c.req.param('id')
      return c.json({ id, name: 'Ada Lovelace' })
    })
    
    // GET /search?q=hono -> query parameter
    app.get('/search', (c) => {
      const query = c.req.query('q')
      return c.json({ query, results: [] })
    })
    
    // POST /users -> JSON body
    app.post('/users', async (c) => {
      const body = await c.req.json()
      return c.json({ id: 'usr_001', ...body }, 201)
    })
    
    export default app
    • `c.req.param('id')` - reads :id from the route pattern /users/:id.
    • `c.req.query('q')` - reads the q parameter from the URL's query string.
    • `await c.req.json()` - parses the request body as JSON. This is async, so the handler needs async.
    • `c.json({...}, 201)` - the second argument sets the HTTP status code. It defaults to 200 if omitted.

    That's enough to build a working CRUD API. The next steps add the pieces that turn it into something you'd actually ship: middleware, validation, and authentication.

  3. 3

    Middleware: Logging, CORS, and Custom Auth Checks

    Middleware in Hono is a function that runs before (and optionally after) a route handler. Hono ships several built-in middleware for common needs, and writing your own is just a function with a next() call.

    typescript โ€” src/index.ts
    import { Hono } from 'hono'
    import { logger } from 'hono/logger'
    import { cors } from 'hono/cors'
    import { timing } from 'hono/timing'
    
    const app = new Hono()
    
    // Runs for every request
    app.use(logger())
    app.use(timing())
    
    // Only applies to routes under /api/*
    app.use('/api/*', cors())
    
    app.get('/api/users', (c) => c.json({ users: [] }))
    
    export default app
    • `logger()` - prints method, path, status, and response time to the console for every request.
    • `cors()` - adds CORS headers so browser-based clients on other origins can call the API.
    • `timing()` - adds a Server-Timing header, useful for spotting slow handlers in devtools.

    app.use(logger()) runs on every route, app-wide. app.use('/api/*', cors()) is scoped: it only runs for paths matching /api/*. This route-level scoping is how you apply different middleware to different parts of the API without nesting routers.

    Custom middleware follows the same shape: a function that takes c and a next callback, does something, then calls await next() to hand off to the next middleware or the route handler.

    typescript โ€” src/middleware/requestId.ts
    import type { MiddlewareHandler } from 'hono'
    
    export const requestId: MiddlewareHandler = async (c, next) => {
      const id = crypto.randomUUID()
      c.set('requestId', id)
    
      await next()
    
      c.header('X-Request-Id', id)
    }

    c.set() and c.get() store values in the request's context, so a value set in one middleware is available in later middleware and in the route handler itself. Code before await next() runs on the way in; code after it runs on the way out, once the response is ready.

    โ„น Centralized error handling

    Don't wrap every handler in try/catch. Call app.onError((err, c) => { ... }) once at the top level. Hono routes any thrown error (including ones thrown inside middleware) to this single handler, where you can log it and return a consistent JSON error shape.

  4. 4

    Input Validation with Zod

    Manually checking if (!body.email) for every field gets unmaintainable fast, and it's easy to miss a case. Hono's official Zod integration validates the request and gives you a fully-typed result in one step.

    bash
    npm install zod @hono/zod-validator
    typescript โ€” src/index.ts
    import { Hono } from 'hono'
    import { zValidator } from '@hono/zod-validator'
    import { z } from 'zod'
    
    const app = new Hono()
    
    const createUserSchema = z.object({
      name: z.string().min(1),
      email: z.string().email(),
      age: z.number().int().positive().optional(),
    })
    
    app.post('/users', zValidator('json', createUserSchema), (c) => {
      // c.req.valid('json') is fully typed from createUserSchema
      const data = c.req.valid('json')
    
      return c.json({ id: 'usr_001', ...data }, 201)
    })
    
    export default app
    • `zValidator('json', schema)` - runs before the handler, parsing and validating the JSON body against schema.
    • `c.req.valid('json')` - returns the parsed body, typed as z.infer<typeof createUserSchema>. No manual casting.
    • `'json'` target - can also be 'query', 'param', 'header', or 'form' for validating other parts of the request.

    If the request body fails validation, the handler never runs. @hono/zod-validator automatically returns a 400 Bad Request with Zod's error details in the response body, so there's no manual try/catch around schema.parse() to write.

    ๐Ÿ’ก Tip

    Define your Zod schemas once and reuse them for both validation and TypeScript types with z.infer<typeof schema>. Your request shape and your type definitions can never drift apart, because they're the same object.

  5. 5

    JWT Authentication

    Hono includes JWT signing and verification as a built-in middleware under hono/jwt, so there's no extra package to install for basic JWT auth.

    typescript โ€” src/index.ts
    import { Hono } from 'hono'
    import { jwt, sign } from 'hono/jwt'
    
    type Bindings = {
      JWT_SECRET: string
    }
    
    const app = new Hono<{ Bindings: Bindings }>()
    
    // Issue a token after checking credentials
    app.post('/login', async (c) => {
      const { username, password } = await c.req.json()
    
      // ... verify username/password against your database here ...
    
      const payload = {
        sub: username,
        exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 24 hours
      }
    
      const token = await sign(payload, c.env.JWT_SECRET)
      return c.json({ token })
    })
    
    // Protect every route under /api/*
    app.use('/api/*', (c, next) => {
      const jwtMiddleware = jwt({ secret: c.env.JWT_SECRET })
      return jwtMiddleware(c, next)
    })
    
    app.get('/api/me', (c) => {
      const payload = c.get('jwtPayload')
      return c.json({ user: payload.sub })
    })
    
    export default app
    • `sign(payload, secret)` - creates a signed JWT. The exp field sets an expiry as a Unix timestamp.
    • `jwt({ secret: ... })` - middleware that checks the Authorization: Bearer <token> header, verifies the signature, and rejects the request with 401 if it's missing or invalid.
    • `c.get('jwtPayload')` - after jwt() middleware passes, the decoded token payload is available here in any downstream handler.

    Reading c.env.JWT_SECRET means the secret comes from the runtime environment, not a hardcoded string in your source. Step 7 covers how to set JWT_SECRET as an encrypted Cloudflare secret so it never ends up in your repo.

    โš  Warning

    Never hardcode JWT_SECRET in source code, even temporarily for testing. If it ends up in git history, anyone who clones the repo can forge valid tokens for your API.

  6. 6

    Organizing a Real API with app.route()

    A single src/index.ts file works for a demo, but a real API with users, posts, and comments needs to be split up. app.route() mounts a separate Hono instance at a path prefix, and each sub-app keeps its own routes, middleware, and types.

    typescript โ€” src/routes/users.ts
    import { Hono } from 'hono'
    import { zValidator } from '@hono/zod-validator'
    import { z } from 'zod'
    
    const users = new Hono()
    
    const createUserSchema = z.object({
      name: z.string().min(1),
      email: z.string().email(),
    })
    
    users.get('/', (c) => c.json({ users: [] }))
    
    users.get('/:id', (c) => {
      const id = c.req.param('id')
      return c.json({ id, name: 'Ada Lovelace' })
    })
    
    users.post('/', zValidator('json', createUserSchema), (c) => {
      const data = c.req.valid('json')
      return c.json({ id: 'usr_001', ...data }, 201)
    })
    
    export default users
    typescript โ€” src/index.ts
    import { Hono } from 'hono'
    import { logger } from 'hono/logger'
    import users from './routes/users'
    import posts from './routes/posts'
    
    const app = new Hono()
    
    app.use(logger())
    
    app.route('/users', users)
    app.route('/posts', posts)
    
    app.get('/', (c) => c.json({ status: 'ok' }))
    
    export default app

    Every route defined on the users sub-app gets prefixed with /users automatically: users.get('/:id', ...) becomes GET /users/:id once mounted. This pattern scales cleanly to 5, 10, or 50 routes, with each resource living in its own file and the top-level src/index.ts staying a short list of mount points.

  7. 7

    Deploying to Cloudflare Workers

    If you used the cloudflare-workers template in Step 1, Wrangler (Cloudflare's CLI) is already installed. Log in once, then deploy with a single command.

    bash
    npx wrangler login
    npx wrangler deploy

    wrangler login opens a browser to authorize the CLI against your Cloudflare account. wrangler deploy bundles src/index.ts and uploads it as a Worker. Your API is live at https://my-api.<your-subdomain>.workers.dev within seconds.

    Configuration lives in wrangler.toml. This is where you define the Worker's name, compatibility settings, and any bindings to KV, D1, or R2:

    toml โ€” wrangler.toml
    name = "my-api"
    main = "src/index.ts"
    compatibility_date = "2026-06-01"
    
    # Optional bindings, only add what you actually use
    [[kv_namespaces]]
    binding = "CACHE"
    id = "your-kv-namespace-id"
    
    [[d1_databases]]
    binding = "DB"
    database_name = "my-api-db"
    database_id = "your-d1-database-id"

    For secrets like JWT_SECRET, never put the value in wrangler.toml (it's checked into git). Use wrangler secret put instead, which stores the value encrypted on Cloudflare's side:

    bash
    npx wrangler secret put JWT_SECRET

    This prompts for the value interactively and never writes it to disk. Both secrets and bindings show up on the same c.env object inside your handlers. Type them with a Bindings type so c.env.JWT_SECRET and c.env.DB are fully typed:

    typescript โ€” src/index.ts
    type Bindings = {
      JWT_SECRET: string
      CACHE: KVNamespace
      DB: D1Database
    }
    
    const app = new Hono<{ Bindings: Bindings }>()

    For local development, npx wrangler dev runs the same Worker runtime locally, including bindings and secrets pulled from a local .dev.vars file, so what you test locally matches production behavior closely.

  8. 8

    Deploying to Node.js Instead

    The same app you built works on Node.js with one extra package: @hono/node-server adapts Hono's Fetch-API-based handler to Node's http module.

    bash
    npm install @hono/node-server
    typescript โ€” src/index.ts
    import { serve } from '@hono/node-server'
    import { Hono } from 'hono'
    
    const app = new Hono()
    
    app.get('/', (c) => c.json({ message: 'Hello from Node.js' }))
    
    serve({
      fetch: app.fetch,
      port: 3000,
    })
    
    console.log('Server running on http://localhost:3000')

    Notice that app itself doesn't change at all, only the entry point does. Every route, middleware, and validator from the earlier steps works identically.

    • Pick Cloudflare Workers when - you want global edge deployment, near-zero cold starts, and pay-per-request pricing with no idle server cost.
    • Pick Node.js when - you have existing infrastructure (a VPS, a container platform, an on-prem server) that you're not migrating, or you need long-running connections and Node-specific APIs that aren't available at the edge.

    There's no urgency to choose one forever. Because the routing and middleware layer is identical, moving a Hono API from Node.js to Workers later (or the reverse) is mostly a change to the entry file, not a rewrite.

  9. 9

    Hono vs Express: The Honest Comparison

    If you're starting a new API that will run on an edge runtime (Cloudflare Workers, Vercel Edge, Deno Deploy), Hono is the better default. Its small size and Fetch-API foundation mean it actually works in those environments, while Express assumes Node's http module and doesn't run on most edge runtimes without a compatibility shim.

    If you already have a working Express API on traditional Node.js infrastructure, there's no urgent reason to rewrite it. Express's ecosystem of middleware is larger and more mature simply because it's been around since 2010. Migrate when you're building something new for the edge, not because Hono is newer.

    Hono and Express side by side.
    HonoExpress
    Bundle size~14KB~500KB+ with common middleware
    TypeScript supportBuilt-in, full type inference for params/body/queriesRequires @types/express, manual typing
    Edge runtime supportNative (Cloudflare Workers, Deno, Bun, Vercel Edge)Limited, often needs a compatibility layer
    Middleware ecosystemSmaller, but covers the essentials (cors, jwt, logger, cache)Very large, mature, battle-tested over 15+ years
    Best use caseNew edge-first APIs, multi-runtime projectsExisting Node.js services, large legacy middleware stacks

Frequently Asked Questions

What is Hono.js?

Hono is a lightweight web framework for building HTTP APIs, built directly on the standard Web Fetch API rather than a specific runtime's APIs. That's what lets the same code run on Cloudflare Workers, Deno, Bun, Node.js, and AWS Lambda without modification.

  • Size - around 14KB with zero dependencies.
  • TypeScript-first - route params, query strings, and validated bodies are typed automatically.
  • Runtime-agnostic - one router, many deployment targets.
Is Hono faster than Express?
HonoExpress
Router implementationRegExpRouter, optimized for high-throughput routingStandard middleware-chain router
Cold start (edge/serverless)Near-instant due to small bundle sizeSlower, larger bundle to initialize
Runtime overheadMinimal, thin layer over Fetch APIHigher, built on Node's http module plus abstractions

In benchmarks, Hono's router consistently outperforms Express, and the difference is most visible in serverless and edge environments where cold start time matters. On a long-running Node.js server handling sustained traffic, the gap narrows considerably since both frameworks are thin layers over fast underlying I/O.

Does Hono work with Node.js?

Yes. Install @hono/node-server and use it to serve your Hono app's fetch handler over Node's http module:

typescript
import { serve } from '@hono/node-server'
import { Hono } from 'hono'

const app = new Hono()
app.get('/', (c) => c.json({ ok: true }))

serve({ fetch: app.fetch, port: 3000 })

โ„น Info

The app itself, routes, middleware, and validators, is identical whether it runs on Node.js or Cloudflare Workers. Only the entry point that calls serve() vs export default app changes.

How do I deploy a Hono API to Cloudflare Workers?
  1. Scaffold with the Cloudflare template - npm create hono@latest my-api and choose cloudflare-workers.
  2. Authenticate the CLI - run npx wrangler login once to connect Wrangler to your Cloudflare account.
  3. Configure wrangler.toml - set the Worker name, main entry file, and compatibility_date.
  4. Add secrets - run npx wrangler secret put JWT_SECRET for any sensitive values, never commit them to wrangler.toml.
  5. Deploy - run npx wrangler deploy, which bundles src/index.ts and publishes it to *.workers.dev.

๐Ÿ’ก Tip

Use npx wrangler dev for local development. It runs the same Workers runtime locally, including bindings and secrets from a .dev.vars file, so local behavior matches production.

Can I use Zod with Hono for input validation?

Yes, via the official @hono/zod-validator package. Define a Zod schema, pass it to zValidator(), and read the validated, typed result with c.req.valid():

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

const schema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
})

app.post('/users', zValidator('json', schema), (c) => {
  const data = c.req.valid('json') // fully typed
  return c.json(data, 201)
})
What happens when Zod validation fails in Hono?

The route handler never runs. @hono/zod-validator intercepts the request, runs the schema's .safeParse() internally, and if it fails, immediately returns a 400 Bad Request response with the Zod error details as JSON.

  • Status code - always 400, not 422 or 500, regardless of which field failed.
  • Body shape - includes a success: false flag and an error object containing Zod's issue list (field paths, messages, and error codes).
  • Handler code - your route function is never invoked, so there's nothing to catch or check manually.

โ„น Info

Need a custom error response shape instead of Zod's default format? Pass a third argument to zValidator: a function (result, c) => { ... } that runs when validation fails, letting you return your own JSON structure and status code.

That's the full path from npm create hono@latest to a deployed, validated, JWT-protected API running on Cloudflare's edge network. The same app object you built also runs unchanged on Node.js if you need that flexibility later.

Next, automate the deploy step instead of running wrangler deploy by hand. The GitHub Actions tutorial covers building a workflow that deploys your Hono API to Cloudflare Workers on every push to main, and GitHub Actions Security: 7 Misconfigurations to Avoid covers locking down that pipeline so your JWT_SECRET and Cloudflare API token can't leak.

For the database layer, the routes in Step 6 are a good place to wire up a real data store. Drizzle ORM migrations with Drizzle Kit walks through connecting a typed ORM to your API and managing schema changes safely.

Related Articles

devops

GitHub Actions Tutorial: CI/CD from Push to Deploy (2026)

Learn GitHub Actions: write your first workflow, run tests automatically, use secrets safely, deploy via SSH, cache dependencies, and run matrix builds.

Jun 12, 2026ยท13 min read
databases

Drizzle ORM Migrations: A Practical drizzle-kit Guide

Learn the full Drizzle ORM migration workflow: push vs migrate, drizzle-kit setup, Turso/libSQL config, team conflicts, and production best practices.

May 30, 2026ยท9 min read
security

GitHub Actions Security: 7 Misconfigurations to Avoid

The 7 GitHub Actions misconfigurations behind real supply chain attacks: weak GITHUB_TOKEN scope, pull_request_target, unpinned actions, script injection.

Jun 12, 2026ยท14 min read

On this page

  • Project Setup with create-hono
  • Your First Routes: Params, Queries, and JSON Bodies
  • Middleware: Logging, CORS, and Custom Auth Checks
  • Input Validation with Zod
  • JWT Authentication
  • Organizing a Real API with app.route()
  • Deploying to Cloudflare Workers
  • Deploying to Node.js Instead
  • Hono vs Express: The Honest Comparison
  • Frequently Asked Questions
Advertisement