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.
On this page
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).
- 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.
bashnpm create hono@latest my-apiYou'll be prompted to pick a template. For this guide, choose
cloudflare-workers. Other options includebun,deno,nodejs,aws-lambda, andvercel, 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
honoand thewranglerCLI as dependencies.
bashcd my-api npm install npm run devnpm run devstarts a local dev server (powered by Wrangler) athttp://localhost:8787. The generated starter looks like this:typescript โ src/index.tsimport { Hono } from 'hono' const app = new Hono() app.get('/', (c) => { return c.json({ message: 'Hello Hono!' }) }) export default appcis 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 throughc.env. Almost everything you do in a Hono handler goes throughc. - 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.tsimport { 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
:idfrom the route pattern/users/:id. - `c.req.query('q')` - reads the
qparameter 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
200if 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.
- `c.req.param('id')` - reads
- 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.tsimport { 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-Timingheader, 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
cand anextcallback, does something, then callsawait next()to hand off to the next middleware or the route handler.typescript โ src/middleware/requestId.tsimport 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()andc.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 beforeawait next()runs on the way in; code after it runs on the way out, once the response is ready. - 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.bashnpm install zod @hono/zod-validatortypescript โ src/index.tsimport { 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-validatorautomatically returns a400 Bad Requestwith Zod's error details in the response body, so there's no manualtry/catcharoundschema.parse()to write. - `zValidator('json', schema)` - runs before the handler, parsing and validating the JSON body against
- 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.tsimport { 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
expfield sets an expiry as a Unix timestamp. - `jwt({ secret: ... })` - middleware that checks the
Authorization: Bearer <token>header, verifies the signature, and rejects the request with401if 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_SECRETmeans the secret comes from the runtime environment, not a hardcoded string in your source. Step 7 covers how to setJWT_SECRETas an encrypted Cloudflare secret so it never ends up in your repo. - `sign(payload, secret)` - creates a signed JWT. The
- 6
Organizing a Real API with app.route()
A single
src/index.tsfile 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.tsimport { 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 userstypescript โ src/index.tsimport { 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 appEvery route defined on the
userssub-app gets prefixed with/usersautomatically:users.get('/:id', ...)becomesGET /users/:idonce mounted. This pattern scales cleanly to 5, 10, or 50 routes, with each resource living in its own file and the top-levelsrc/index.tsstaying a short list of mount points. - 7
Deploying to Cloudflare Workers
If you used the
cloudflare-workerstemplate in Step 1, Wrangler (Cloudflare's CLI) is already installed. Log in once, then deploy with a single command.bashnpx wrangler login npx wrangler deploywrangler loginopens a browser to authorize the CLI against your Cloudflare account.wrangler deploybundlessrc/index.tsand uploads it as a Worker. Your API is live athttps://my-api.<your-subdomain>.workers.devwithin 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.tomlname = "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 inwrangler.toml(it's checked into git). Usewrangler secret putinstead, which stores the value encrypted on Cloudflare's side:bashnpx wrangler secret put JWT_SECRETThis prompts for the value interactively and never writes it to disk. Both secrets and bindings show up on the same
c.envobject inside your handlers. Type them with aBindingstype soc.env.JWT_SECRETandc.env.DBare fully typed:typescript โ src/index.tstype Bindings = { JWT_SECRET: string CACHE: KVNamespace DB: D1Database } const app = new Hono<{ Bindings: Bindings }>()For local development,
npx wrangler devruns the same Worker runtime locally, including bindings and secrets pulled from a local.dev.varsfile, so what you test locally matches production behavior closely. - 8
Deploying to Node.js Instead
The same
appyou built works on Node.js with one extra package:@hono/node-serveradapts Hono's Fetch-API-based handler to Node'shttpmodule.bashnpm install @hono/node-servertypescript โ src/index.tsimport { 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
appitself 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
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
httpmodule 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. Hono Express Bundle size ~14KB ~500KB+ with common middleware TypeScript support Built-in, full type inference for params/body/queries Requires @types/express, manual typing Edge runtime support Native (Cloudflare Workers, Deno, Bun, Vercel Edge) Limited, often needs a compatibility layer Middleware ecosystem Smaller, but covers the essentials (cors, jwt, logger, cache) Very large, mature, battle-tested over 15+ years Best use case New edge-first APIs, multi-runtime projects Existing 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?
| Hono | Express | |
|---|---|---|
| Router implementation | RegExpRouter, optimized for high-throughput routing | Standard middleware-chain router |
| Cold start (edge/serverless) | Near-instant due to small bundle size | Slower, larger bundle to initialize |
| Runtime overhead | Minimal, thin layer over Fetch API | Higher, 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:
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 })How do I deploy a Hono API to Cloudflare Workers?
- Scaffold with the Cloudflare template -
npm create hono@latest my-apiand choosecloudflare-workers. - Authenticate the CLI - run
npx wrangler loginonce to connect Wrangler to your Cloudflare account. - Configure wrangler.toml - set the Worker
name,mainentry file, andcompatibility_date. - Add secrets - run
npx wrangler secret put JWT_SECRETfor any sensitive values, never commit them towrangler.toml. - Deploy - run
npx wrangler deploy, which bundlessrc/index.tsand publishes it to*.workers.dev.
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():
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, not422or500, regardless of which field failed. - Body shape - includes a
success: falseflag and anerrorobject 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.
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
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.
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.
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.