Skip to main content
/ Tutorial

Build a REST API with Hono on Cloudflare Workers

Sacha Roussakis-NotterSacha Roussakis-Notter
25 min read
Hono
Cloudflare
TypeScript
Source
Share

Learn to build production-ready REST APIs using Hono on Cloudflare Workers. Complete tutorial with TypeScript, Zod validation, D1 database, and deployment.

Introduction

Hono is an ultrafast, lightweight web framework built on Web Standards that runs natively on Cloudflare Workers. With 27,900+ GitHub stars and 6+ million weekly npm downloads, it has become the go-to choice for building edge APIs. This tutorial teaches you how to build a complete REST API with CRUD operations, validation, and database integration using proper modular architecture.

Hono API Tutorial Series
Part 1 of 2
1

You are here

Build a REST API

Previous

Start of series

Next

Secure Your API

Tutorial Seriesbuun.group

Why Hono + Cloudflare Workers?

FeatureBenefit
8x faster than ExpressMinimal abstraction, maximum performance
Under 14KB bundleShips less JavaScript, faster cold starts
Zero cold startsV8 isolates instead of containers
300+ edge locationsYour API runs closest to users globally
TypeScript-firstFull type inference without configuration
Free tier generous100K requests/day at no cost

Prerequisites

Before You Begin
0/5
0% completebuun.group

Install Wrangler CLI

Wrangler is Cloudflare's CLI tool for managing Workers projects.

Install Wrangler Globally
npm install -g wrangler
3 optionsbuun.group
Verify and Login
$wrangler --version
wrangler 4.0.0
# Opens browser for authentication
$wrangler login
2 commandsbuun.group

API Token Permissions

``wrangler login`` uses OAuth and grants full access to your account. For CI/CD pipelines or restricted access, create a custom API token instead.

Required permissions for this tutorial:

PermissionAccess LevelUsed For
Account → Workers ScriptsEditDeploy Workers, manage code
Account → D1EditCreate databases, run migrations
Account → Workers KV StorageEditOnly if using KV (optional)
Zone → Workers RoutesEditOnly for custom domain routing
Create API Token
1 / 6

1Navigate to API Tokens

Go to dash.cloudflare.com → My Profile (top right) → API Tokens → Create Token

Step 1 of 6
6 stepsbuun.group

Use your token with Wrangler:

bash
1# Option 1: Environment variable (recommended for CI/CD)
2export CLOUDFLARE_API_TOKEN="your-token-here"
3
4# Option 2: Wrangler config
5wrangler config
6# Paste your token when prompted

Step 1: Project Setup

Create Project
1 / 3

1Scaffold Project

Use create-hono to scaffold a Cloudflare Workers project with TypeScript.

bash
npm create hono@latest my-api -- --template cloudflare-workers
Step 1 of 3
3 stepsbuun.group

Project Structure

We'll use a modular architecture from the start - clean, scalable, and easy to navigate:

Project Structure
my-api/
my-api/
src/
index.ts// App entry - mounts routes
routes/
index.ts// Route exports
posts.ts// Posts CRUD handlers
schemas/
index.ts// Schema exports
post.ts// Validation schemas
middleware/
index.ts// Middleware exports
error-handler.ts// Global error handling
types/
bindings.ts// Cloudflare bindings type
docs/
API.md// API documentation
tests/
api.test.ts// API tests
setup.ts// Test setup (D1 tables)
cloudflare-test.d.ts// Test type definitions
migrations/
0001_create_posts.sql// Database schema
wrangler.toml// Cloudflare config
vitest.config.ts// Test config
package.json
tsconfig.json
1 item at rootbuun.group

Step 2: Type Definitions

Start by defining your Cloudflare bindings type. This gives you full TypeScript support for D1, KV, and environment variables.

typescript
1// src/types/bindings.ts
2export type Bindings = {
3 DB: D1Database
4}
5
6export type Variables = {
7 requestId: string
8}
9
10export type AppEnv = {
11 Bindings: Bindings
12 Variables: Variables
13}

Step 3: Validation Schemas

Define your Zod schemas in a dedicated file. Export inferred TypeScript types for reuse.

typescript
1// src/schemas/post.ts
2import { z } from 'zod'
3
4export const createPostSchema = z.object({
5 title: z.string().min(1, 'Title is required').max(200, 'Title too long'),
6 body: z.string().min(1, 'Body is required'),
7 published: z.boolean().default(false),
8})
9
10export const updatePostSchema = createPostSchema.partial()
11
12export const listPostsQuerySchema = z.object({
13 page: z.coerce.number().positive().default(1),
14 limit: z.coerce.number().min(1).max(100).default(10),
15 published: z.enum(['true', 'false']).optional(),
16})
17
18// Infer types from schemas for reuse
19export type CreatePost = z.infer<typeof createPostSchema>
20export type UpdatePost = z.infer<typeof updatePostSchema>
typescript
1// src/schemas/index.ts
2export * from './post'

Step 4: Error Handling Middleware

Create centralized error handling to keep your routes clean.

typescript
1// src/middleware/error-handler.ts
2import type { Context } from 'hono'
3import { HTTPException } from 'hono/http-exception'
4
5export function errorHandler(err: Error, c: Context) {
6 console.error(`[Error] ${err.message}`, err.stack)
7
8 if (err instanceof HTTPException) {
9 return c.json({ error: err.message }, err.status)
10 }
11
12 if (err.name === 'ZodError') {
13 return c.json({
14 error: 'Validation failed',
15 details: (err as any).issues,
16 }, 400)
17 }
18
19 if (err.message?.includes('D1')) {
20 return c.json({ error: 'Database error' }, 503)
21 }
22
23 return c.json({ error: 'Internal server error' }, 500)
24}
25
26export function notFoundHandler(c: Context) {
27 return c.json({ error: 'Not found', path: c.req.path }, 404)
28}
typescript
1// src/middleware/index.ts
2export { errorHandler, notFoundHandler } from './error-handler'

Step 5: Route Handlers

Build your CRUD routes in a dedicated file. Import schemas and types for full type safety.

typescript
1// src/routes/posts.ts
2import { Hono } from 'hono'
3import { zValidator } from '@hono/zod-validator'
4import { HTTPException } from 'hono/http-exception'
5import type { AppEnv } from '../types/bindings'
6import {
7 createPostSchema,
8 updatePostSchema,
9 listPostsQuerySchema,
10} from '../schemas'
11
12const posts = new Hono<AppEnv>()
13
14// GET /posts - List all posts with pagination
15posts.get('/', zValidator('query', listPostsQuerySchema), async (c) => {
16 const { page, limit, published } = c.req.valid('query')
17 const offset = (page - 1) * limit
18
19 let query = 'SELECT * FROM posts'
20 const params: (string | number)[] = []
21
22 if (published !== undefined) {
23 query += ' WHERE published = ?'
24 params.push(published === 'true' ? 1 : 0)
25 }
26
27 query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
28 params.push(limit, offset)
29
30 const { results } = await c.env.DB
31 .prepare(query)
32 .bind(...params)
33 .all()
34
35 return c.json({ posts: results, page, limit })
36})
37
38// GET /posts/:id - Get single post
39posts.get('/:id', async (c) => {
40 const id = c.req.param('id')
41
42 const post = await c.env.DB
43 .prepare('SELECT * FROM posts WHERE id = ?')
44 .bind(id)
45 .first()
46
47 if (!post) {
48 throw new HTTPException(404, { message: 'Post not found' })
49 }
50
51 return c.json(post)
52})
53
54// POST /posts - Create new post
55posts.post('/', zValidator('json', createPostSchema), async (c) => {
56 const data = c.req.valid('json')
57 const id = crypto.randomUUID()
58
59 await c.env.DB
60 .prepare('INSERT INTO posts (id, title, body, published) VALUES (?, ?, ?, ?)')
61 .bind(id, data.title, data.body, data.published ? 1 : 0)
62 .run()
63
64 return c.json({ id, ...data }, 201)
65})
66
67// PUT /posts/:id - Update post
68posts.put('/:id', zValidator('json', updatePostSchema), async (c) => {
69 const id = c.req.param('id')
70 const data = c.req.valid('json')
71
72 const existing = await c.env.DB
73 .prepare('SELECT * FROM posts WHERE id = ?')
74 .bind(id)
75 .first()
76
77 if (!existing) {
78 throw new HTTPException(404, { message: 'Post not found' })
79 }
80
81 await c.env.DB
82 .prepare(`
83 UPDATE posts
84 SET title = ?, body = ?, published = ?, updated_at = CURRENT_TIMESTAMP
85 WHERE id = ?
86 `)
87 .bind(
88 data.title ?? existing.title,
89 data.body ?? existing.body,
90 data.published !== undefined ? (data.published ? 1 : 0) : existing.published,
91 id
92 )
93 .run()
94
95 return c.json({ id, ...data })
96})
97
98// DELETE /posts/:id - Delete post
99posts.delete('/:id', async (c) => {
100 const id = c.req.param('id')
101
102 const result = await c.env.DB
103 .prepare('DELETE FROM posts WHERE id = ?')
104 .bind(id)
105 .run()
106
107 if (result.meta.changes === 0) {
108 throw new HTTPException(404, { message: 'Post not found' })
109 }
110
111 return c.json({ deleted: true })
112})
113
114export { posts }
typescript
1// src/routes/index.ts
2export { posts } from './posts'

Step 6: Main Application Entry

The main index.ts is clean and minimal - it just wires everything together.

typescript
1// src/index.ts
2import { Hono } from 'hono'
3import { cors } from 'hono/cors'
4import { logger } from 'hono/logger'
5import { secureHeaders } from 'hono/secure-headers'
6
7import type { AppEnv } from './types/bindings'
8import { posts } from './routes'
9import { errorHandler, notFoundHandler } from './middleware'
10
11const app = new Hono<AppEnv>()
12
13// Global middleware
14app.use('*', logger())
15app.use('*', secureHeaders())
16app.use('/api/*', cors({
17 origin: ['https://example.com'],
18 credentials: true,
19}))
20
21// Health check
22app.get('/health', (c) => c.json({ status: 'ok' }))
23
24// Mount routes
25app.route('/api/posts', posts)
26
27// Error handling
28app.onError(errorHandler)
29app.notFound(notFoundHandler)
30
31export default app

Step 7: Database Setup

Configure wrangler.toml

toml
1# wrangler.toml
2name = "my-api"
3main = "src/index.ts"
4compatibility_date = "2024-12-01"
5
6[[d1_databases]]
7binding = "DB"
8database_name = "my-database"
9database_id = "your-database-id-here"
10
11# Development settings - custom port to avoid conflicts
12[dev]
13port = 5138
14local_protocol = "http"

Create D1 Database

Create D1 Database
$wrangler d1 create my-database
Created database 'my-database'
# Copy the database_id to wrangler.toml
$wrangler d1 info my-database
2 commandsbuun.group

Create Migration

sql
1-- migrations/0001_create_posts.sql
2CREATE TABLE IF NOT EXISTS posts (
3 id TEXT PRIMARY KEY,
4 title TEXT NOT NULL,
5 body TEXT NOT NULL,
6 published INTEGER DEFAULT 0,
7 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
8 updated_at TEXT DEFAULT CURRENT_TIMESTAMP
9);

Apply Migration

Apply Migration
$npm run db:migrate:local
3 commands executed successfully
1 commandbuun.group

Run Development Server

Start Local Development
$npm run dev
Ready on http://localhost:5138
1 commandbuun.group

Test Your API Locally

terminal
${
$"title": "Test API Endpoints",
$"commands": [
${ "command": "curl -s http://localhost:5138/health | jq", "output": "{ "status": "ok" }", "success": true },
${ "command": "curl -s http://localhost:5138/api/posts | jq", "output": "{ "posts": [], "page": 1 }", "success": true },
${ "command": "curl -s -X POST http://localhost:5138/api/posts -H 'Content-Type: application/json' -d '{"title":"Hello","body":"World","published":true}' | jq", "output": "{ "id": "...", "title": "Hello" }", "success": true }
$]
$}
8 commandsbuun.group

Step 8: Testing Your API

Install Test Dependencies

Install Testing Dependencies
npm install -D vitest @cloudflare/vitest-pool-workers
3 optionsbuun.group

Configure Vitest for Workers

typescript
1// vitest.config.ts
2import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'
3
4export default defineWorkersConfig({
5 test: {
6 globals: true,
7 setupFiles: ['./tests/setup.ts'],
8 poolOptions: {
9 workers: {
10 wrangler: { configPath: './wrangler.toml' },
11 miniflare: {
12 // Enable D1 for tests with an in-memory database
13 d1Databases: {
14 DB: 'test-db',
15 },
16 },
17 },
18 },
19 },
20})

Add Test Type Definitions

typescript
1// tests/cloudflare-test.d.ts
2import type { Bindings } from '../src/types/bindings'
3
4declare module 'cloudflare:test' {
5 // eslint-disable-next-line @typescript-eslint/no-empty-object-type
6 interface ProvidedEnv extends Bindings {}
7}

Create Test Setup

typescript
1// tests/setup.ts
2import { env } from 'cloudflare:test'
3import { beforeAll } from 'vitest'
4
5// Create tables before running tests
6beforeAll(async () => {
7 await env.DB.exec(
8 'CREATE TABLE IF NOT EXISTS posts (id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL, published INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT DEFAULT CURRENT_TIMESTAMP);'
9 )
10})

Write Tests

typescript
1// tests/api.test.ts
2import { describe, it, expect } from 'vitest'
3import { env } from 'cloudflare:test'
4import app from '../src/index'
5
6// Type definitions for API responses
7type HealthResponse = { status: string }
8type PostsListResponse = { posts: unknown[]; page: number; limit: number }
9type PostResponse = { id: string; title: string; body: string; published: boolean }
10type ErrorResponse = { error: string }
11
12describe('Health Check', () => {
13 it('GET /health returns 200', async () => {
14 const res = await app.request('/health', {}, env)
15 expect(res.status).toBe(200)
16
17 const data = (await res.json()) as HealthResponse
18 expect(data).toEqual({ status: 'ok' })
19 })
20})
21
22describe('Posts API', () => {
23 it('GET /api/posts returns 200', async () => {
24 const res = await app.request('/api/posts', {}, env)
25 expect(res.status).toBe(200)
26
27 const data = (await res.json()) as PostsListResponse
28 expect(data).toHaveProperty('posts')
29 expect(data).toHaveProperty('page')
30 expect(data).toHaveProperty('limit')
31 })
32
33 it('POST /api/posts creates a post', async () => {
34 const res = await app.request('/api/posts', {
35 method: 'POST',
36 headers: { 'Content-Type': 'application/json' },
37 body: JSON.stringify({
38 title: 'Test Post',
39 body: 'Test content for the post body',
40 published: true,
41 }),
42 }, env)
43
44 expect(res.status).toBe(201)
45 const data = (await res.json()) as PostResponse
46 expect(data.title).toBe('Test Post')
47 expect(data.id).toBeDefined()
48 })
49
50 it('POST /api/posts validates input', async () => {
51 const res = await app.request('/api/posts', {
52 method: 'POST',
53 headers: { 'Content-Type': 'application/json' },
54 body: JSON.stringify({
55 title: '', // Invalid: empty
56 body: 'Content',
57 }),
58 }, env)
59
60 expect(res.status).toBe(400)
61 })
62
63 it('GET /api/posts/unknown returns 404', async () => {
64 const res = await app.request('/api/posts/unknown-id-12345', {}, env)
65 expect(res.status).toBe(404)
66 })
67})
68
69describe('Not Found Handler', () => {
70 it('returns 404 for unknown routes', async () => {
71 const res = await app.request('/unknown-route', {}, env)
72 expect(res.status).toBe(404)
73
74 const data = (await res.json()) as ErrorResponse
75 expect(data.error).toBe('Not found')
76 })
77})

Update tsconfig.json for Tests

Add the vitest pool workers types:

json
1// tsconfig.json (types array)
2{
3 "compilerOptions": {
4 "types": [
5 "@cloudflare/workers-types",
6 "@cloudflare/vitest-pool-workers",
7 "vitest/globals"
8 ]
9 }
10}
Run Tests
$npm run typecheck
No errors
$npm test
8 passed
2 commandsbuun.group

Step 9: Deployment

Deploy to Cloudflare
$npm run deploy
Published my-api to https://my-api.username.workers.dev
1 commandbuun.group

Set Production Secrets

Manage Secrets
# Add secret
$echo 'your-api-key' | wrangler secret put API_KEY
# List all secrets
$wrangler secret list
2 commandsbuun.group

Test Live API

terminal
${
$"title": "Test Production API",
$"commands": [
${ "command": "curl https://my-api.username.workers.dev/health", "output": "{"status":"ok"}", "success": true },
${ "command": "curl https://my-api.username.workers.dev/api/posts", "comment": "List posts" }
$]
$}
7 commandsbuun.group
Complete Project Structure
my-api/
my-api/
src/
index.ts// App entry - mounts routes
routes/
index.ts// Route exports
posts.ts// Posts CRUD handlers
schemas/
index.ts// Schema exports
post.ts// Post validation schemas
middleware/
index.ts// Middleware exports
error-handler.ts// Global error handling
types/
bindings.ts// Cloudflare bindings type
docs/
API.md// Full API documentation
tests/
api.test.ts// API tests
setup.ts// Test setup
cloudflare-test.d.ts// Test types
migrations/
0001_create_posts.sql
wrangler.toml
vitest.config.ts
tsconfig.json
package.json
1 item at rootbuun.group

API Documentation

Create a docs/API.md file to document your endpoints. This is a best practice for production APIs.

What to include in docs/API.md:

SectionContent
Base URLlocalhost:5138 (dev), your-worker.workers.dev (prod)
EndpointsMethod, path, description for each route
Request BodyField, type, required, description table
Query ParamsPagination, filtering options
ResponseStatus codes, JSON schema examples
curl ExamplesCopy-paste commands for testing

See the starter repo docs/API.md for a complete example.

Cloudflare Workers Pricing

pricing

Cloudflare Workers Pricing

ResourceFree TierPaid Plan ($5/month)
Requests100K/day10M/mo included, then $0.30/million
CPU Time10ms/request30M ms/mo, then $0.02/million ms
D1 Reads5M rows/day25B rows/mo, then $0.001/million
D1 Storage5 GB5 GB, then $0.75/GB-month
WorkersUp to 100Unlimited
  • *Free tier resets daily at midnight UTC.
  • *All prices in USD. Verify on official pricing page.
Last updated: January 2026buun.group

Common Issues & Debugging

Troubleshooting FAQ
5 items

The client must send the Content-Type: application/json header. Without it, the validator cannot parse the body.

Use c.env.VARIABLE_NAME, not process.env. Cloudflare Workers don't have Node.js globals.

You must await next() in middleware. Forgetting await is the most common middleware mistake.

Run 'wrangler dev --inspect' and press [d] to open Chrome DevTools for debugging.

Run 'wrangler tail' to stream real-time logs from your deployed Worker.

0 of 5 expandedbuun.group

Best Practices Recap

Production Checklist
0/6
0% completebuun.group

Quiz: Test Your Knowledge

Check Your Understanding
1/4

How do you access environment variables in a Cloudflare Worker with Hono?

Interactive quizbuun.group

Continue the Series

Hono API Tutorial Series
Part 1 of 2
1

You are here

Build a REST API

Previous

Start of series

Next

Secure Your API

Tutorial Seriesbuun.group

Brisbane/Queensland Context

This architecture powers APIs for Queensland businesses, delivering sub-50ms response times from Cloudflare's Sydney and Melbourne edge locations. For Australian users, edge computing dramatically reduces latency compared to traditional cloud regions.

Need help building your edge API?

Topics

Hono frameworkCloudflare WorkersREST API tutorialTypeScript APIZod validationD1 databaseedge computingserverless API

Share this post

Share

Comments

Sign in to join the conversation

Login

No comments yet. Be the first to share your thoughts!

Found an issue with this article?

/ Let's Talk

Want to work with us?

Whether you need help with architecture, development, or technical consulting, our team is here to help bring your vision to life.