Tutorial Hono.js dan Cloudflare Workers: Buat REST API Ultrafast - Nayaka Yoga Pradipta
ID | EN

Tutorial Hono.js dan Cloudflare Workers: Buat REST API Ultrafast

Kamis, 25 Des 2025

Kamu mau bikin REST API yang bisa handle ribuan request dengan latency di bawah 50ms? Dan deploynya gratis? Hono.js + Cloudflare Workers jawabannya.

Hono.js (炎 yang artinya “api” dalam bahasa Jepang) adalah web framework yang didesain khusus untuk edge runtime. Ultrafast, lightweight, dan developer experience-nya juara.

Versi yang Digunakan

PackageVersi
Node.js18+
Wrangler3.x+
Hono4.x
Vitest3.2.x

Kenapa Hono.js?

Sebelum kita mulai coding, mari kita pahami kenapa Hono.js layak jadi pilihan:

  1. Ultrafast - Benchmark menunjukkan Hono 2-3x lebih cepat dari Express
  2. Edge-first - Didesain untuk edge runtime (Cloudflare Workers, Deno, Bun)
  3. Tiny - Cuma ~14KB, tidak ada dependency
  4. TypeScript-first - Type inference yang excellent
  5. Multi-runtime - Bisa jalan di Node.js, Deno, Bun, Cloudflare Workers
// Hello World dengan Hono - simple banget
import { Hono } from 'hono'

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

export default app

Apa itu Cloudflare Workers?

Cloudflare Workers adalah serverless platform yang menjalankan code di edge network Cloudflare. Artinya, API kamu jalan di 300+ data center di seluruh dunia, dekat dengan user.

Keuntungan Cloudflare Workers:

FiturKeterangan
0ms cold startTidak ada cold start seperti AWS Lambda
Global edgeDeploy otomatis ke 300+ lokasi
Free tier murah hati100,000 request/hari gratis
D1 DatabaseSQLite database di edge
KV StorageKey-value store dengan latency rendah
Durable ObjectsStateful serverless dengan consistency

Hono vs Express: Perbandingan

Kalau kamu familiar dengan Express, berikut perbandingannya:

// 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
AspekExpressHono
Bundle Size~2MB dengan deps~14KB
Cold Start200-500ms0ms di Workers
RuntimeNode.js onlyMulti-runtime
TypeScriptButuh setupBuilt-in
MiddlewareCallback-basedModern async

Setup Project dengan Wrangler CLI

Mari kita mulai bikin project REST API untuk todo app.

1. Install Wrangler

Wrangler adalah CLI untuk develop dan deploy Cloudflare Workers.

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

2. Login ke Cloudflare

wrangler login

Ini akan buka browser untuk autentikasi dengan akun Cloudflare kamu.

3. Buat Project Baru

npm create hono@latest my-api

Tip: Untuk CI/CD atau non-interactive environment, gunakan flag --template:

npm create hono@latest my-api -- --template cloudflare-workers

Pilih cloudflare-workers sebagai template:

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

4. Struktur Project

Setelah create, struktur project seperti ini:

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

5. Install Dependencies

cd my-api
npm install

6. Jalankan Development Server

npm run dev

Buka http://localhost:8787 dan kamu akan lihat “Hello Hono!”.

Basic Routing dengan Hono

Hono punya routing yang intuitif. Mari kita 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 ke main app
app.route('/api/v1', v1)
app.route('/api/v2', v2)

export default app

Middleware: CORS, Auth, Logger

Middleware di Hono sangat powerful dan mudah digunakan.

Built-in Middleware

Hono punya banyak middleware built-in:

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 semua request
app.use('*', logger())

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

// Secure Headers - tambah 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 (lebih 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 (contoh sederhana)
    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

Mari buat REST API lengkap untuk todo app:

// filepath: /src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { validator } from 'hono/validator'
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 untuk validation:

npm install zod @hono/zod-validator

Note: Jika terjadi dependency conflict, gunakan flag --legacy-peer-deps atau pin versi zod:

npm install [email protected] @hono/zod-validator

Connect ke D1 Database

D1 adalah SQLite database dari Cloudflare yang berjalan di edge. Perfect untuk aplikasi yang butuh database dengan latency rendah.

1. Buat D1 Database

wrangler d1 create todo-db

Output akan memberikan 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. Buat Schema

Buat 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', 'Belajar Hono.js', 0, '2024-01-01T00:00:00Z'),
  ('2', 'Deploy ke Cloudflare', 0, '2024-01-01T00:00:00Z');

4. Apply Schema

Untuk local development:

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

Untuk production:

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

5. Query Database

// Type bindings
type Bindings = {
  DB: D1Database
}

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

// SELECT
app.get('/todos', async (c) => {
  const { results } = await c.env.DB.prepare(
    'SELECT * FROM todos WHERE completed = ?'
  ).bind(0).all()
  
  return c.json(results)
})

// INSERT
app.post('/todos', async (c) => {
  const { title } = await c.req.json()
  
  const result = await c.env.DB.prepare(
    'INSERT INTO todos (id, title, created_at) VALUES (?, ?, ?)'
  ).bind(crypto.randomUUID(), title, new Date().toISOString()).run()
  
  return c.json({ success: result.success })
})

// Batch queries
app.post('/batch', async (c) => {
  const results = await c.env.DB.batch([
    c.env.DB.prepare('SELECT * FROM todos'),
    c.env.DB.prepare('SELECT COUNT(*) as count FROM todos'),
  ])
  
  return c.json(results)
})

Environment Variables dan Secrets

1. Vars di wrangler.toml

Untuk non-sensitive config:

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

[vars]
ENVIRONMENT = "development"
API_VERSION = "v1"

2. Secrets dengan Wrangler

Untuk sensitive data seperti API keys:

# Set secret
wrangler secret put JWT_SECRET

# Akan prompt untuk input value
Enter a secret value: ********

3. Akses di Code

type Bindings = {
  DB: D1Database
  JWT_SECRET: string
  ENVIRONMENT: string
  API_VERSION: string
}

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

app.get('/config', (c) => {
  // Akses vars
  const env = c.env.ENVIRONMENT
  const version = c.env.API_VERSION
  
  return c.json({ env, version })
})

app.use('/api/*', async (c, next) => {
  // Akses secret
  const secret = c.env.JWT_SECRET
  // Use for JWT verification...
  await next()
})

4. Environment-specific Config

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

# Development
[env.dev.vars]
ENVIRONMENT = "development"
LOG_LEVEL = "debug"

# Production
[env.production.vars]
ENVIRONMENT = "production"
LOG_LEVEL = "error"

Deploy ke environment specific:

# Development
wrangler deploy --env dev

# Production
wrangler deploy --env production

Deploy ke Cloudflare Workers

1. Build dan Deploy

npm run deploy
# atau
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

Di Cloudflare Dashboard:

  1. Buka Workers & Pages
  2. Pilih worker kamu
  3. Tab “Triggers”
  4. Add Custom Domain

Atau via wrangler.toml:

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

3. Preview Deployments

# Deploy ke preview URL (tidak affect production)
wrangler deploy --env preview

4. Rollback

# List deployments
wrangler deployments list

# Rollback ke version sebelumnya
wrangler rollback

Testing dengan Vitest

Hono support testing dengan Vitest out of the box.

1. Setup Vitest

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

Note: Package @cloudflare/vitest-pool-workers saat ini hanya support Vitest versi 2.0.x - 3.2.x. Pastikan untuk pin versi Vitest seperti contoh di atas.

2. Konfigurasi Vitest

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

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

3. Tulis Tests

// filepath: /src/index.test.ts
import { describe, it, expect, beforeAll } from 'vitest'
import { env, createExecutionContext, waitOnExecutionContext } 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)
    })
  })

  describe('PUT /todos/:id', () => {
    it('should update todo', async () => {
      // Create todo first
      const createRes = await app.request('/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: 'Original' }),
      }, env)
      
      const { todo } = await createRes.json()
      
      // Update it
      const updateRes = await app.request(`/todos/${todo.id}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: 'Updated', completed: true }),
      }, env)
      
      expect(updateRes.status).toBe(200)
      
      const data = await updateRes.json()
      expect(data.todo.title).toBe('Updated')
      expect(data.todo.completed).toBe(true)
    })
  })

  describe('DELETE /todos/:id', () => {
    it('should delete todo', async () => {
      // Create todo first
      const createRes = await app.request('/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title: 'To Delete' }),
      }, env)
      
      const { todo } = await createRes.json()
      
      // Delete it
      const deleteRes = await app.request(`/todos/${todo.id}`, {
        method: 'DELETE',
      }, env)
      
      expect(deleteRes.status).toBe(200)
      
      // Verify deleted
      const getRes = await app.request(`/todos/${todo.id}`, {}, env)
      expect(getRes.status).toBe(404)
    })
  })
})

4. Run Tests

npm test
# atau
vitest run

Tambahkan script di package.json:

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

Bonus: OpenAPI Documentation

Hono punya package untuk generate OpenAPI spec:

npm install @hono/zod-openapi
import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi'

const app = new OpenAPIHono()

const getTodosRoute = createRoute({
  method: 'get',
  path: '/todos',
  responses: {
    200: {
      content: {
        'application/json': {
          schema: z.object({
            todos: z.array(z.object({
              id: z.string(),
              title: z.string(),
              completed: z.boolean(),
            })),
          }),
        },
      },
      description: 'List of todos',
    },
  },
})

app.openapi(getTodosRoute, async (c) => {
  // Your handler
})

// Generate OpenAPI JSON
app.doc('/doc', {
  openapi: '3.0.0',
  info: {
    title: 'Todo API',
    version: '1.0.0',
  },
})

// Swagger UI
app.get('/ui', swaggerUI({ url: '/doc' }))

Kesimpulan

Hono.js + Cloudflare Workers adalah combo yang powerful untuk bikin REST API:

  • Setup mudah - Cuma butuh beberapa menit
  • Developer experience - TypeScript first, middleware yang intuitive
  • Performance - Ultrafast dengan 0ms cold start
  • Scalable - Auto-scale di 300+ edge locations
  • Cost effective - 100K request/hari gratis

Untuk project selanjutnya, kamu bisa explore:

  • Hono RPC - Type-safe API calls antara client dan server
  • KV Storage - Untuk caching dan session
  • Queues - Background job processing
  • Durable Objects - Stateful serverless

Happy coding! 🔥