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:
- Ultrafast - Benchmarks show Hono is 2-3x faster than Express
- Edge-first - Designed for edge runtimes (Cloudflare Workers, Deno, Bun)
- Tiny - Only ~14KB, no dependencies
- TypeScript-first - Excellent type inference
- 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:
| Feature | Description |
|---|---|
| 0ms cold start | No cold start like AWS Lambda |
| Global edge | Auto-deploy to 300+ locations |
| Generous free tier | 100,000 requests/day free |
| D1 Database | SQLite database at the edge |
| KV Storage | Key-value store with low latency |
| Durable Objects | Stateful 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
| Aspect | Express | Hono |
|---|---|---|
| Bundle Size | ~2MB with deps | ~14KB |
| Cold Start | 200-500ms | 0ms on Workers |
| Runtime | Node.js only | Multi-runtime |
| TypeScript | Needs setup | Built-in |
| Middleware | Callback-based | Modern 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:
- Open Workers & Pages
- Select your worker
- Tab “Triggers”
- 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! 🔥