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.
You are here
Build a REST API
Previous
Start of series
Next
Secure Your API
All parts in this series
Why Hono + Cloudflare Workers?
| Feature | Benefit |
|---|---|
| 8x faster than Express | Minimal abstraction, maximum performance |
| Under 14KB bundle | Ships less JavaScript, faster cold starts |
| Zero cold starts | V8 isolates instead of containers |
| 300+ edge locations | Your API runs closest to users globally |
| TypeScript-first | Full type inference without configuration |
| Free tier generous | 100K requests/day at no cost |
Prerequisites
Install Wrangler CLI
Wrangler is Cloudflare's CLI tool for managing Workers projects.
npm install -g wranglerAPI 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:
| Permission | Access Level | Used For |
|---|---|---|
| Account → Workers Scripts | Edit | Deploy Workers, manage code |
| Account → D1 | Edit | Create databases, run migrations |
| Account → Workers KV Storage | Edit | Only if using KV (optional) |
| Zone → Workers Routes | Edit | Only for custom domain routing |
1Navigate to API Tokens
Go to dash.cloudflare.com → My Profile (top right) → API Tokens → Create Token
Use your token with Wrangler:
1# Option 1: Environment variable (recommended for CI/CD)2export CLOUDFLARE_API_TOKEN="your-token-here"34# Option 2: Wrangler config5wrangler config6# Paste your token when promptedStep 1: Project Setup
1Scaffold Project
Use create-hono to scaffold a Cloudflare Workers project with TypeScript.
npm create hono@latest my-api -- --template cloudflare-workersProject Structure
We'll use a modular architecture from the start - clean, scalable, and easy to navigate:
Step 2: Type Definitions
Start by defining your Cloudflare bindings type. This gives you full TypeScript support for D1, KV, and environment variables.
1// src/types/bindings.ts2export type Bindings = {3 DB: D1Database4}56export type Variables = {7 requestId: string8}910export type AppEnv = {11 Bindings: Bindings12 Variables: Variables13}Step 3: Validation Schemas
Define your Zod schemas in a dedicated file. Export inferred TypeScript types for reuse.
1// src/schemas/post.ts2import { z } from 'zod'34export 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})910export const updatePostSchema = createPostSchema.partial()1112export 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})1718// Infer types from schemas for reuse19export type CreatePost = z.infer<typeof createPostSchema>20export type UpdatePost = z.infer<typeof updatePostSchema>1// src/schemas/index.ts2export * from './post'Step 4: Error Handling Middleware
Create centralized error handling to keep your routes clean.
1// src/middleware/error-handler.ts2import type { Context } from 'hono'3import { HTTPException } from 'hono/http-exception'45export function errorHandler(err: Error, c: Context) {6 console.error(`[Error] ${err.message}`, err.stack)78 if (err instanceof HTTPException) {9 return c.json({ error: err.message }, err.status)10 }1112 if (err.name === 'ZodError') {13 return c.json({14 error: 'Validation failed',15 details: (err as any).issues,16 }, 400)17 }1819 if (err.message?.includes('D1')) {20 return c.json({ error: 'Database error' }, 503)21 }2223 return c.json({ error: 'Internal server error' }, 500)24}2526export function notFoundHandler(c: Context) {27 return c.json({ error: 'Not found', path: c.req.path }, 404)28}1// src/middleware/index.ts2export { 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.
1// src/routes/posts.ts2import { 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'1112const posts = new Hono<AppEnv>()1314// GET /posts - List all posts with pagination15posts.get('/', zValidator('query', listPostsQuerySchema), async (c) => {16 const { page, limit, published } = c.req.valid('query')17 const offset = (page - 1) * limit1819 let query = 'SELECT * FROM posts'20 const params: (string | number)[] = []2122 if (published !== undefined) {23 query += ' WHERE published = ?'24 params.push(published === 'true' ? 1 : 0)25 }2627 query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'28 params.push(limit, offset)2930 const { results } = await c.env.DB31 .prepare(query)32 .bind(...params)33 .all()3435 return c.json({ posts: results, page, limit })36})3738// GET /posts/:id - Get single post39posts.get('/:id', async (c) => {40 const id = c.req.param('id')4142 const post = await c.env.DB43 .prepare('SELECT * FROM posts WHERE id = ?')44 .bind(id)45 .first()4647 if (!post) {48 throw new HTTPException(404, { message: 'Post not found' })49 }5051 return c.json(post)52})5354// POST /posts - Create new post55posts.post('/', zValidator('json', createPostSchema), async (c) => {56 const data = c.req.valid('json')57 const id = crypto.randomUUID()5859 await c.env.DB60 .prepare('INSERT INTO posts (id, title, body, published) VALUES (?, ?, ?, ?)')61 .bind(id, data.title, data.body, data.published ? 1 : 0)62 .run()6364 return c.json({ id, ...data }, 201)65})6667// PUT /posts/:id - Update post68posts.put('/:id', zValidator('json', updatePostSchema), async (c) => {69 const id = c.req.param('id')70 const data = c.req.valid('json')7172 const existing = await c.env.DB73 .prepare('SELECT * FROM posts WHERE id = ?')74 .bind(id)75 .first()7677 if (!existing) {78 throw new HTTPException(404, { message: 'Post not found' })79 }8081 await c.env.DB82 .prepare(`83 UPDATE posts84 SET title = ?, body = ?, published = ?, updated_at = CURRENT_TIMESTAMP85 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 id92 )93 .run()9495 return c.json({ id, ...data })96})9798// DELETE /posts/:id - Delete post99posts.delete('/:id', async (c) => {100 const id = c.req.param('id')101102 const result = await c.env.DB103 .prepare('DELETE FROM posts WHERE id = ?')104 .bind(id)105 .run()106107 if (result.meta.changes === 0) {108 throw new HTTPException(404, { message: 'Post not found' })109 }110111 return c.json({ deleted: true })112})113114export { posts }1// src/routes/index.ts2export { posts } from './posts'Step 6: Main Application Entry
The main index.ts is clean and minimal - it just wires everything together.
1// src/index.ts2import { Hono } from 'hono'3import { cors } from 'hono/cors'4import { logger } from 'hono/logger'5import { secureHeaders } from 'hono/secure-headers'67import type { AppEnv } from './types/bindings'8import { posts } from './routes'9import { errorHandler, notFoundHandler } from './middleware'1011const app = new Hono<AppEnv>()1213// Global middleware14app.use('*', logger())15app.use('*', secureHeaders())16app.use('/api/*', cors({17 origin: ['https://example.com'],18 credentials: true,19}))2021// Health check22app.get('/health', (c) => c.json({ status: 'ok' }))2324// Mount routes25app.route('/api/posts', posts)2627// Error handling28app.onError(errorHandler)29app.notFound(notFoundHandler)3031export default appStep 7: Database Setup
Configure wrangler.toml
1# wrangler.toml2name = "my-api"3main = "src/index.ts"4compatibility_date = "2024-12-01"56[[d1_databases]]7binding = "DB"8database_name = "my-database"9database_id = "your-database-id-here"1011# Development settings - custom port to avoid conflicts12[dev]13port = 513814local_protocol = "http"Create D1 Database
Create Migration
1-- migrations/0001_create_posts.sql2CREATE 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_TIMESTAMP9);Apply Migration
Run Development Server
Test Your API Locally
Step 8: Testing Your API
Install Test Dependencies
npm install -D vitest @cloudflare/vitest-pool-workersConfigure Vitest for Workers
1// vitest.config.ts2import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'34export 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 database13 d1Databases: {14 DB: 'test-db',15 },16 },17 },18 },19 },20})Add Test Type Definitions
1// tests/cloudflare-test.d.ts2import type { Bindings } from '../src/types/bindings'34declare module 'cloudflare:test' {5 // eslint-disable-next-line @typescript-eslint/no-empty-object-type6 interface ProvidedEnv extends Bindings {}7}Create Test Setup
1// tests/setup.ts2import { env } from 'cloudflare:test'3import { beforeAll } from 'vitest'45// Create tables before running tests6beforeAll(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
1// tests/api.test.ts2import { describe, it, expect } from 'vitest'3import { env } from 'cloudflare:test'4import app from '../src/index'56// Type definitions for API responses7type 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 }1112describe('Health Check', () => {13 it('GET /health returns 200', async () => {14 const res = await app.request('/health', {}, env)15 expect(res.status).toBe(200)1617 const data = (await res.json()) as HealthResponse18 expect(data).toEqual({ status: 'ok' })19 })20})2122describe('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)2627 const data = (await res.json()) as PostsListResponse28 expect(data).toHaveProperty('posts')29 expect(data).toHaveProperty('page')30 expect(data).toHaveProperty('limit')31 })3233 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)4344 expect(res.status).toBe(201)45 const data = (await res.json()) as PostResponse46 expect(data.title).toBe('Test Post')47 expect(data.id).toBeDefined()48 })4950 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: empty56 body: 'Content',57 }),58 }, env)5960 expect(res.status).toBe(400)61 })6263 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})6869describe('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)7374 const data = (await res.json()) as ErrorResponse75 expect(data.error).toBe('Not found')76 })77})Update tsconfig.json for Tests
Add the vitest pool workers types:
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}Step 9: Deployment
Set Production Secrets
Test Live API
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:
| Section | Content |
|---|---|
| Base URL | localhost:5138 (dev), your-worker.workers.dev (prod) |
| Endpoints | Method, path, description for each route |
| Request Body | Field, type, required, description table |
| Query Params | Pagination, filtering options |
| Response | Status codes, JSON schema examples |
| curl Examples | Copy-paste commands for testing |
See the starter repo docs/API.md for a complete example.
Cloudflare Workers Pricing
Cloudflare Workers Pricing
| Resource | Free Tier | Paid Plan ($5/month) |
|---|---|---|
| Requests | 100K/day | 10M/mo included, then $0.30/million |
| CPU Time | 10ms/request | 30M ms/mo, then $0.02/million ms |
| D1 Reads | 5M rows/day | 25B rows/mo, then $0.001/million |
| D1 Storage | 5 GB | 5 GB, then $0.75/GB-month |
| Workers | Up to 100 | Unlimited |
- *Free tier resets daily at midnight UTC.
- *All prices in USD. Verify on official pricing page.
Common Issues & Debugging
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.
Best Practices Recap
Quiz: Test Your Knowledge
How do you access environment variables in a Cloudflare Worker with Hono?
Continue the Series
You are here
Build a REST API
Previous
Start of series
Next
Secure Your API
Next Steps
Starter Repository
Clone the complete code from this tutorial
Hono Official Documentation
Complete API reference and guides
Hono RPC for Type-Safe Clients
End-to-end type safety between server and client
Cloudflare D1 Documentation
Deep dive into D1 database features
Zod Documentation
Advanced schema validation patterns
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
Comments
Sign in to join the conversation
LoginNo comments yet. Be the first to share your thoughts!
Found an issue with this article?
