Hono.js + Cloudflare Workers: Build Ultrafast REST API
ID | EN

Hono.js + Cloudflare Workers: Build Ultrafast REST API

Thursday, Dec 25, 2025

Want to build a REST API that can handle thousands of requests with latency under 50ms? And deploy it for free? Hono.js + Cloudflare Workers is the answer.

Hono.js (炎 which means “flame” in Japanese) is a web framework designed specifically for edge runtimes. Ultrafast, lightweight, and the developer experience is excellent.

Why Hono.js?

Before we start coding, let’s understand why Hono.js is worth choosing:

  1. Ultrafast - Benchmarks show Hono is 2-3x faster than Express
  2. Edge-first - Designed for edge runtimes (Cloudflare Workers, Deno, Bun)
  3. Tiny - Only ~14KB, no dependencies
  4. TypeScript-first - Excellent type inference
  5. Multi-runtime - Can run on Node.js, Deno, Bun, Cloudflare Workers
// Hello World with Hono - super simple
import { Hono } from 'hono'

const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))

export default app

What is Cloudflare Workers?

Cloudflare Workers is a serverless platform that runs code on Cloudflare’s edge network. This means your API runs in 300+ data centers worldwide, close to users.

Cloudflare Workers Advantages:

FeatureDescription
0ms cold startNo cold start like AWS Lambda
Global edgeAuto-deploy to 300+ locations
Generous free tier100,000 requests/day free
D1 DatabaseSQLite database at the edge
KV StorageKey-value store with low latency
Durable ObjectsStateful serverless with consistency

Hono vs Express: Comparison

If you’re familiar with Express, here’s a comparison:

// Express.js
import express from 'express'
const app = express()

app.get('/users/:id', (req, res) => {
  const { id } = req.params
  res.json({ id, name: 'John' })
})

app.listen(3000)
// Hono.js
import { Hono } from 'hono'
const app = new Hono()

app.get('/users/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ id, name: 'John' })
})

export default app
AspectExpressHono
Bundle Size~2MB with deps~14KB
Cold Start200-500ms0ms on Workers
RuntimeNode.js onlyMulti-runtime
TypeScriptNeeds setupBuilt-in
MiddlewareCallback-basedModern async

Project Setup with Wrangler CLI

Let’s start building a REST API for a todo app.

1. Install Wrangler

Wrangler is the CLI for developing and deploying Cloudflare Workers.

npm install -g wrangler
# or
pnpm add -g wrangler

2. Login to Cloudflare

wrangler login

This will open a browser for authentication with your Cloudflare account.

3. Create New Project

npm create hono@latest my-api

Choose cloudflare-workers as template:

? Which template do you want to use?
  aws-lambda
  bun
❯ cloudflare-workers
  cloudflare-pages
  deno
  fastly
  nodejs

4. Project Structure

After creating, the project structure looks like this:

my-api/
├── src/
│   └── index.ts        # Entry point
├── wrangler.toml       # Cloudflare config
├── package.json
└── tsconfig.json

5. Install Dependencies

cd my-api
npm install

6. Run Development Server

npm run dev

Open http://localhost:8787 and you’ll see “Hello Hono!”.

Basic Routing with Hono

Hono has intuitive routing. Let’s explore:

Route Methods

// filepath: /src/index.ts
import { Hono } from 'hono'

const app = new Hono()

// GET request
app.get('/users', (c) => c.json({ users: [] }))

// POST request
app.post('/users', async (c) => {
  const body = await c.req.json()
  return c.json({ created: body }, 201)
})

// PUT request
app.put('/users/:id', async (c) => {
  const id = c.req.param('id')
  const body = await c.req.json()
  return c.json({ updated: { id, ...body } })
})

// DELETE request
app.delete('/users/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ deleted: id })
})

export default app

Route Parameters

// Single parameter
app.get('/users/:id', (c) => {
  const id = c.req.param('id')
  return c.json({ id })
})

// Multiple parameters
app.get('/users/:userId/posts/:postId', (c) => {
  const { userId, postId } = c.req.param()
  return c.json({ userId, postId })
})

// Optional parameter
app.get('/posts/:id?', (c) => {
  const id = c.req.param('id')
  if (id) {
    return c.json({ post: id })
  }
  return c.json({ posts: [] })
})

Query Parameters

// GET /search?q=hono&limit=10
app.get('/search', (c) => {
  const query = c.req.query('q')
  const limit = c.req.query('limit')
  
  return c.json({ 
    query, 
    limit: parseInt(limit || '10') 
  })
})

// Get all query params
app.get('/filter', (c) => {
  const queries = c.req.queries()
  return c.json(queries)
})

Route Groups

import { Hono } from 'hono'

const app = new Hono()

// API v1 routes
const v1 = new Hono()
v1.get('/users', (c) => c.json({ version: 'v1', users: [] }))
v1.get('/posts', (c) => c.json({ version: 'v1', posts: [] }))

// API v2 routes
const v2 = new Hono()
v2.get('/users', (c) => c.json({ version: 'v2', users: [] }))

// Mount to main app
app.route('/api/v1', v1)
app.route('/api/v2', v2)

export default app

Middleware: CORS, Auth, Logger

Middleware in Hono is very powerful and easy to use.

Built-in Middleware

Hono has many built-in middleware:

import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { prettyJSON } from 'hono/pretty-json'
import { secureHeaders } from 'hono/secure-headers'

const app = new Hono()

// Logger - log all requests
app.use('*', logger())

// Pretty JSON - format JSON response
app.use('*', prettyJSON())

// Secure Headers - add security headers
app.use('*', secureHeaders())

// CORS - allow cross-origin requests
app.use('/api/*', cors({
  origin: ['https://myapp.com', 'http://localhost:3000'],
  allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
  allowHeaders: ['Content-Type', 'Authorization'],
  maxAge: 86400,
}))

Custom Middleware

import { Hono, Next } from 'hono'
import { Context } from 'hono'

const app = new Hono()

// Timing middleware
const timing = async (c: Context, next: Next) => {
  const start = Date.now()
  await next()
  const duration = Date.now() - start
  c.header('X-Response-Time', `${duration}ms`)
}

app.use('*', timing)

// Request ID middleware
const requestId = async (c: Context, next: Next) => {
  const id = crypto.randomUUID()
  c.set('requestId', id)
  c.header('X-Request-ID', id)
  await next()
}

app.use('*', requestId)

Authentication Middleware

import { Hono } from 'hono'
import { bearerAuth } from 'hono/bearer-auth'
import { jwt } from 'hono/jwt'

const app = new Hono()

// Simple Bearer Token Auth
app.use('/api/*', bearerAuth({ token: 'my-secret-token' }))

// JWT Auth (more secure)
app.use('/api/*', jwt({
  secret: 'my-jwt-secret',
}))

// Custom auth middleware
const authMiddleware = async (c: Context, next: Next) => {
  const authHeader = c.req.header('Authorization')
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return c.json({ error: 'Unauthorized' }, 401)
  }
  
  const token = authHeader.substring(7)
  
  try {
    // Verify token (simple example)
    const payload = await verifyToken(token)
    c.set('user', payload)
    await next()
  } catch {
    return c.json({ error: 'Invalid token' }, 401)
  }
}

app.use('/protected/*', authMiddleware)

CRUD API Example: Todo App

Let’s build a complete REST API for a todo app:

// filepath: /src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

// Types
interface Todo {
  id: string
  title: string
  completed: boolean
  createdAt: string
}

type Bindings = {
  DB: D1Database
}

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

// Middleware
app.use('*', logger())
app.use('*', cors())

// Validation schema
const createTodoSchema = z.object({
  title: z.string().min(1).max(200),
})

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

// Routes
// GET /todos - List all todos
app.get('/todos', async (c) => {
  const { results } = await c.env.DB.prepare(
    'SELECT * FROM todos ORDER BY created_at DESC'
  ).all<Todo>()
  
  return c.json({ todos: results })
})

// GET /todos/:id - Get single todo
app.get('/todos/:id', async (c) => {
  const id = c.req.param('id')
  
  const todo = await c.env.DB.prepare(
    'SELECT * FROM todos WHERE id = ?'
  ).bind(id).first<Todo>()
  
  if (!todo) {
    return c.json({ error: 'Todo not found' }, 404)
  }
  
  return c.json({ todo })
})

// POST /todos - Create todo
app.post('/todos', zValidator('json', createTodoSchema), async (c) => {
  const { title } = c.req.valid('json')
  const id = crypto.randomUUID()
  const createdAt = new Date().toISOString()
  
  await c.env.DB.prepare(
    'INSERT INTO todos (id, title, completed, created_at) VALUES (?, ?, ?, ?)'
  ).bind(id, title, false, createdAt).run()
  
  return c.json({ 
    todo: { id, title, completed: false, createdAt } 
  }, 201)
})

// PUT /todos/:id - Update todo
app.put('/todos/:id', zValidator('json', updateTodoSchema), async (c) => {
  const id = c.req.param('id')
  const updates = c.req.valid('json')
  
  // Check if exists
  const existing = await c.env.DB.prepare(
    'SELECT * FROM todos WHERE id = ?'
  ).bind(id).first<Todo>()
  
  if (!existing) {
    return c.json({ error: 'Todo not found' }, 404)
  }
  
  // Build update query
  const newTitle = updates.title ?? existing.title
  const newCompleted = updates.completed ?? existing.completed
  
  await c.env.DB.prepare(
    'UPDATE todos SET title = ?, completed = ? WHERE id = ?'
  ).bind(newTitle, newCompleted, id).run()
  
  return c.json({ 
    todo: { ...existing, title: newTitle, completed: newCompleted } 
  })
})

// DELETE /todos/:id - Delete todo
app.delete('/todos/:id', async (c) => {
  const id = c.req.param('id')
  
  const result = await c.env.DB.prepare(
    'DELETE FROM todos WHERE id = ?'
  ).bind(id).run()
  
  if (result.meta.changes === 0) {
    return c.json({ error: 'Todo not found' }, 404)
  }
  
  return c.json({ deleted: id })
})

// Error handling
app.onError((err, c) => {
  console.error(`Error: ${err.message}`)
  return c.json({ error: 'Internal Server Error' }, 500)
})

// 404 handler
app.notFound((c) => {
  return c.json({ error: 'Not Found' }, 404)
})

export default app

Install Zod for validation:

npm install zod @hono/zod-validator

Connect to D1 Database

D1 is Cloudflare’s SQLite database that runs at the edge. Perfect for applications that need low-latency databases.

1. Create D1 Database

wrangler d1 create todo-db

Output will provide the database ID:

✅ Successfully created DB 'todo-db'

[[d1_databases]]
binding = "DB"
database_name = "todo-db"
database_id = "xxxx-xxxx-xxxx-xxxx"

2. Update wrangler.toml

# filepath: /wrangler.toml
name = "my-api"
main = "src/index.ts"
compatibility_date = "2024-01-01"

[[d1_databases]]
binding = "DB"
database_name = "todo-db"
database_id = "xxxx-xxxx-xxxx-xxxx"

3. Create Schema

Create file schema.sql:

-- filepath: /schema.sql
DROP TABLE IF EXISTS todos;

CREATE TABLE todos (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  completed INTEGER DEFAULT 0,
  created_at TEXT NOT NULL
);

-- Sample data
INSERT INTO todos (id, title, completed, created_at) VALUES
  ('1', 'Learn Hono.js', 0, '2024-01-01T00:00:00Z'),
  ('2', 'Deploy to Cloudflare', 0, '2024-01-01T00:00:00Z');

4. Apply Schema

For local development:

wrangler d1 execute todo-db --local --file=./schema.sql

For production:

wrangler d1 execute todo-db --file=./schema.sql

Deploy to Cloudflare Workers

1. Build and Deploy

npm run deploy
# or
wrangler deploy

Output:

⛅️ wrangler 3.x.x
-------------------
Uploaded my-api (1.23 sec)
Published my-api (0.12 sec)
  https://my-api.your-subdomain.workers.dev

2. Custom Domain

In Cloudflare Dashboard:

  1. Open Workers & Pages
  2. Select your worker
  3. Tab “Triggers”
  4. Add Custom Domain

Or via wrangler.toml:

routes = [
  { pattern = "api.yourdomain.com/*", zone_name = "yourdomain.com" }
]

Testing with Vitest

Hono supports testing with Vitest out of the box.

1. Setup Vitest

npm install -D vitest @cloudflare/vitest-pool-workers

2. Configure Vitest

// filepath: /vitest.config.ts
import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'

export default defineWorkersConfig({
  test: {
    poolOptions: {
      workers: {
        wrangler: { configPath: './wrangler.toml' },
      },
    },
  },
})

3. Write Tests

// filepath: /src/index.test.ts
import { describe, it, expect } from 'vitest'
import { env } from 'cloudflare:test'
import app from './index'

describe('Todo API', () => {
  describe('GET /todos', () => {
    it('should return empty array initially', async () => {
      const res = await app.request('/todos', {}, env)
      
      expect(res.status).toBe(200)
      
      const data = await res.json()
      expect(data.todos).toBeInstanceOf(Array)
    })
  })

  describe('POST /todos', () => {
    it('should create a new todo', async () => {
      const res = await app.request('/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: 'Test todo' }),
      }, env)
      
      expect(res.status).toBe(201)
      
      const data = await res.json()
      expect(data.todo.title).toBe('Test todo')
      expect(data.todo.completed).toBe(false)
    })

    it('should return 400 for invalid input', async () => {
      const res = await app.request('/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: '' }),
      }, env)
      
      expect(res.status).toBe(400)
    })
  })
})

4. Run Tests

npm test
# or
vitest run

Add script to package.json:

{
  "scripts": {
    "dev": "wrangler dev",
    "deploy": "wrangler deploy",
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

Conclusion

Hono.js + Cloudflare Workers is a powerful combo for building REST APIs:

  • Easy setup - Only takes a few minutes
  • Developer experience - TypeScript first, intuitive middleware
  • Performance - Ultrafast with 0ms cold start
  • Scalable - Auto-scale across 300+ edge locations
  • Cost effective - 100K requests/day free

For your next project, you can explore:

  • Hono RPC - Type-safe API calls between client and server
  • KV Storage - For caching and sessions
  • Queues - Background job processing
  • Durable Objects - Stateful serverless

Happy coding! 🔥