Building REST APIs with Hono and Bun: A Modern Alternative to Express

Building REST APIs with Hono and Bun: A Modern Alternative to Express
The JavaScript ecosystem has evolved dramatically. While Express.js served us well for over a decade, modern alternatives offer better performance, type safety, and developer experience. In this comprehensive tutorial, we'll explore Hono - an ultrafast web framework - paired with Bun - a blazingly fast JavaScript runtime.
Why Hono + Bun?
Before diving into code, let's understand why this combination is gaining massive traction:
Bun Runtime Advantages
- Speed: Up to 4x faster than Node.js for many operations
- Built-in TypeScript: No transpilation needed
- Native bundler: No webpack or esbuild required
- SQLite built-in: Database ready out of the box
- npm compatible: Use your existing packages
Hono Framework Benefits
- Ultralight: ~14KB, zero dependencies
- Multi-runtime: Works on Bun, Node, Deno, Cloudflare Workers
- Type-safe: First-class TypeScript support
- Modern API: Middleware, routing, validation built-in
- Web Standards: Uses Fetch API, Request/Response objects
Prerequisites
Before starting, ensure you have:
- Basic JavaScript/TypeScript knowledge
- Familiarity with REST API concepts
- Terminal/command line comfort
- A code editor (VS Code recommended)
This tutorial uses Bun 1.1+ and Hono 4.x. Commands may vary slightly with different versions.
Step 1: Installing Bun
If you haven't installed Bun yet, it's a one-liner:
# macOS and Linux
curl -fsSL https://bun.sh/install | bash
# Windows (using PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"Verify the installation:
bun --version
# Should output: 1.1.x or higherStep 2: Creating Your Project
Let's scaffold a new project:
# Create project directory
mkdir hono-api && cd hono-api
# Initialize with Bun
bun init -y
# Install Hono
bun add hono
# Install development dependencies
bun add -d @types/bunYour project structure should look like:
hono-api/
├── node_modules/
├── package.json
├── tsconfig.json
├── bun.lockb
└── index.ts
Step 3: Your First Hono Server
Replace the contents of index.ts with:
import { Hono } from 'hono'
const app = new Hono()
// Root route
app.get('/', (c) => {
return c.json({
message: 'Welcome to Hono API!',
version: '1.0.0',
timestamp: new Date().toISOString()
})
})
// Health check endpoint
app.get('/health', (c) => {
return c.json({ status: 'healthy' })
})
// Start server
export default {
port: 3000,
fetch: app.fetch
}Run your server:
bun run index.tsVisit http://localhost:3000 - you should see the JSON response!
Bun supports hot reloading out of the box. Use bun --watch index.ts during development for automatic restarts.
Step 4: Structuring Your API
For a production-ready API, let's organize our code properly. Create this folder structure:
hono-api/
├── src/
│ ├── index.ts
│ ├── routes/
│ │ ├── index.ts
│ │ ├── users.ts
│ │ └── products.ts
│ ├── middleware/
│ │ ├── auth.ts
│ │ └── logger.ts
│ ├── services/
│ │ └── database.ts
│ └── types/
│ └── index.ts
├── package.json
└── tsconfig.json
Create the directory structure:
mkdir -p src/{routes,middleware,services,types}
touch src/index.ts
touch src/routes/{index,users,products}.ts
touch src/middleware/{auth,logger}.ts
touch src/services/database.ts
touch src/types/index.tsStep 5: Defining Types
In src/types/index.ts:
export interface User {
id: string
name: string
email: string
createdAt: Date
}
export interface Product {
id: string
name: string
price: number
description: string
stock: number
}
export interface ApiResponse<T> {
success: boolean
data?: T
error?: string
meta?: {
page?: number
total?: number
}
}Step 6: Creating a Logger Middleware
In src/middleware/logger.ts:
import { MiddlewareHandler } from 'hono'
export const logger: MiddlewareHandler = async (c, next) => {
const start = Date.now()
const method = c.req.method
const path = c.req.path
console.log(`→ ${method} ${path}`)
await next()
const duration = Date.now() - start
const status = c.res.status
console.log(`← ${method} ${path} ${status} (${duration}ms)`)
}Step 7: Building Authentication Middleware
In src/middleware/auth.ts:
import { MiddlewareHandler } from 'hono'
import { HTTPException } from 'hono/http-exception'
// Simple API key authentication
export const authMiddleware: MiddlewareHandler = async (c, next) => {
const apiKey = c.req.header('X-API-Key')
if (!apiKey) {
throw new HTTPException(401, {
message: 'API key is required'
})
}
// In production, validate against database
const validKeys = ['demo-key-123', 'test-key-456']
if (!validKeys.includes(apiKey)) {
throw new HTTPException(403, {
message: 'Invalid API key'
})
}
// Attach user info to context
c.set('apiKey', apiKey)
await next()
}
// JWT authentication example
export const jwtAuth: MiddlewareHandler = async (c, next) => {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
throw new HTTPException(401, {
message: 'Bearer token required'
})
}
const token = authHeader.slice(7)
try {
// In production, verify JWT properly
// const payload = await verifyJwt(token)
// c.set('user', payload)
await next()
} catch {
throw new HTTPException(401, {
message: 'Invalid token'
})
}
}Step 8: Setting Up Database Service
Using Bun's built-in SQLite for simplicity. In src/services/database.ts:
import { Database } from 'bun:sqlite'
import type { User, Product } from '../types'
// Initialize database
const db = new Database('app.db')
// Create tables
db.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`)
db.run(`
CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
price REAL NOT NULL,
description TEXT,
stock INTEGER DEFAULT 0
)
`)
// User operations
export const userService = {
findAll(): User[] {
return db.query('SELECT * FROM users').all() as User[]
},
findById(id: string): User | null {
return db.query('SELECT * FROM users WHERE id = ?').get(id) as User | null
},
create(user: Omit<User, 'createdAt'>): User {
const stmt = db.prepare(
'INSERT INTO users (id, name, email) VALUES (?, ?, ?)'
)
stmt.run(user.id, user.name, user.email)
return this.findById(user.id)!
},
update(id: string, data: Partial<User>): User | null {
const fields = Object.keys(data)
.map(k => `${k} = ?`)
.join(', ')
const values = Object.values(data)
db.prepare(`UPDATE users SET ${fields} WHERE id = ?`).run(...values, id)
return this.findById(id)
},
delete(id: string): boolean {
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
return result.changes > 0
}
}
// Product operations
export const productService = {
findAll(): Product[] {
return db.query('SELECT * FROM products').all() as Product[]
},
findById(id: string): Product | null {
return db.query('SELECT * FROM products WHERE id = ?').get(id) as Product | null
},
create(product: Product): Product {
const stmt = db.prepare(
'INSERT INTO products (id, name, price, description, stock) VALUES (?, ?, ?, ?, ?)'
)
stmt.run(product.id, product.name, product.price, product.description, product.stock)
return this.findById(product.id)!
},
update(id: string, data: Partial<Product>): Product | null {
const fields = Object.keys(data)
.map(k => `${k} = ?`)
.join(', ')
const values = Object.values(data)
db.prepare(`UPDATE products SET ${fields} WHERE id = ?`).run(...values, id)
return this.findById(id)
},
delete(id: string): boolean {
const result = db.prepare('DELETE FROM products WHERE id = ?').run(id)
return result.changes > 0
}
}
export { db }Step 9: Creating User Routes
In src/routes/users.ts:
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { userService } from '../services/database'
import type { ApiResponse, User } from '../types'
const users = new Hono()
// Validation schemas
const createUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email()
})
const updateUserSchema = z.object({
name: z.string().min(2).max(100).optional(),
email: z.string().email().optional()
})
// GET /users - List all users
users.get('/', (c) => {
const allUsers = userService.findAll()
const response: ApiResponse<User[]> = {
success: true,
data: allUsers,
meta: { total: allUsers.length }
}
return c.json(response)
})
// GET /users/:id - Get single user
users.get('/:id', (c) => {
const id = c.req.param('id')
const user = userService.findById(id)
if (!user) {
return c.json<ApiResponse<null>>({
success: false,
error: 'User not found'
}, 404)
}
return c.json<ApiResponse<User>>({
success: true,
data: user
})
})
// POST /users - Create user
users.post(
'/',
zValidator('json', createUserSchema),
(c) => {
const body = c.req.valid('json')
const id = crypto.randomUUID()
try {
const user = userService.create({ id, ...body })
return c.json<ApiResponse<User>>({
success: true,
data: user
}, 201)
} catch (error) {
return c.json<ApiResponse<null>>({
success: false,
error: 'Email already exists'
}, 409)
}
}
)
// PUT /users/:id - Update user
users.put(
'/:id',
zValidator('json', updateUserSchema),
(c) => {
const id = c.req.param('id')
const body = c.req.valid('json')
const user = userService.update(id, body)
if (!user) {
return c.json<ApiResponse<null>>({
success: false,
error: 'User not found'
}, 404)
}
return c.json<ApiResponse<User>>({
success: true,
data: user
})
}
)
// DELETE /users/:id - Delete user
users.delete('/:id', (c) => {
const id = c.req.param('id')
const deleted = userService.delete(id)
if (!deleted) {
return c.json<ApiResponse<null>>({
success: false,
error: 'User not found'
}, 404)
}
return c.json<ApiResponse<null>>({
success: true
}, 204)
})
export default usersDon't forget to install Zod for validation:
bun add zod @hono/zod-validatorStep 10: Creating Product Routes
In src/routes/products.ts:
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { productService } from '../services/database'
import type { ApiResponse, Product } from '../types'
const products = new Hono()
const productSchema = z.object({
name: z.string().min(1).max(200),
price: z.number().positive(),
description: z.string().optional(),
stock: z.number().int().min(0).default(0)
})
// GET /products
products.get('/', (c) => {
const query = c.req.query('search')
let allProducts = productService.findAll()
if (query) {
allProducts = allProducts.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
)
}
return c.json<ApiResponse<Product[]>>({
success: true,
data: allProducts,
meta: { total: allProducts.length }
})
})
// GET /products/:id
products.get('/:id', (c) => {
const product = productService.findById(c.req.param('id'))
if (!product) {
return c.json<ApiResponse<null>>({
success: false,
error: 'Product not found'
}, 404)
}
return c.json<ApiResponse<Product>>({
success: true,
data: product
})
})
// POST /products
products.post(
'/',
zValidator('json', productSchema),
(c) => {
const body = c.req.valid('json')
const id = crypto.randomUUID()
const product = productService.create({
id,
name: body.name,
price: body.price,
description: body.description || '',
stock: body.stock
})
return c.json<ApiResponse<Product>>({
success: true,
data: product
}, 201)
}
)
// PUT /products/:id
products.put(
'/:id',
zValidator('json', productSchema.partial()),
(c) => {
const id = c.req.param('id')
const body = c.req.valid('json')
const product = productService.update(id, body)
if (!product) {
return c.json<ApiResponse<null>>({
success: false,
error: 'Product not found'
}, 404)
}
return c.json<ApiResponse<Product>>({
success: true,
data: product
})
}
)
// DELETE /products/:id
products.delete('/:id', (c) => {
const deleted = productService.delete(c.req.param('id'))
if (!deleted) {
return c.json<ApiResponse<null>>({
success: false,
error: 'Product not found'
}, 404)
}
return c.json({ success: true }, 204)
})
export default productsStep 11: Combining Routes
In src/routes/index.ts:
import { Hono } from 'hono'
import users from './users'
import products from './products'
const routes = new Hono()
routes.route('/users', users)
routes.route('/products', products)
export default routesStep 12: Main Application Entry Point
Update src/index.ts:
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { prettyJSON } from 'hono/pretty-json'
import { HTTPException } from 'hono/http-exception'
import { logger } from './middleware/logger'
import { authMiddleware } from './middleware/auth'
import routes from './routes'
const app = new Hono()
// Global middleware
app.use('*', logger)
app.use('*', cors({
origin: ['http://localhost:3000', 'https://yourdomain.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key']
}))
app.use('*', prettyJSON())
// Public routes
app.get('/', (c) => {
return c.json({
name: 'Hono REST API',
version: '1.0.0',
docs: '/docs',
health: '/health'
})
})
app.get('/health', (c) => c.json({ status: 'ok' }))
// Protected API routes
app.use('/api/*', authMiddleware)
app.route('/api', routes)
// Global error handler
app.onError((err, c) => {
console.error('Error:', err)
if (err instanceof HTTPException) {
return c.json({
success: false,
error: err.message
}, err.status)
}
return c.json({
success: false,
error: 'Internal server error'
}, 500)
})
// 404 handler
app.notFound((c) => {
return c.json({
success: false,
error: 'Route not found'
}, 404)
})
// Export for Bun
export default {
port: process.env.PORT || 3000,
fetch: app.fetch
}
console.log('🚀 Server running on http://localhost:3000')Step 13: Testing Your API
Start the server:
bun run src/index.tsTest with curl:
# Health check
curl http://localhost:3000/health
# Create a user (with API key)
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-H "X-API-Key: demo-key-123" \
-d '{"name": "John Doe", "email": "john@example.com"}'
# List users
curl http://localhost:3000/api/users \
-H "X-API-Key: demo-key-123"
# Create a product
curl -X POST http://localhost:3000/api/products \
-H "Content-Type: application/json" \
-H "X-API-Key: demo-key-123" \
-d '{"name": "Laptop", "price": 999.99, "stock": 50}'Never commit API keys to version control. Use environment variables for sensitive data in production.
Step 14: Adding OpenAPI Documentation
Hono supports OpenAPI/Swagger. Install the package:
bun add @hono/swagger-uiAdd documentation endpoint to src/index.ts:
import { swaggerUI } from '@hono/swagger-ui'
// Add after other routes
app.get('/docs', swaggerUI({ url: '/openapi.json' }))
app.get('/openapi.json', (c) => {
return c.json({
openapi: '3.0.0',
info: {
title: 'Hono REST API',
version: '1.0.0',
description: 'A modern REST API built with Hono and Bun'
},
servers: [
{ url: 'http://localhost:3000', description: 'Development' }
],
paths: {
'/api/users': {
get: {
summary: 'List all users',
security: [{ apiKey: [] }],
responses: {
'200': { description: 'List of users' }
}
}
}
// Add more paths...
},
components: {
securitySchemes: {
apiKey: {
type: 'apiKey',
in: 'header',
name: 'X-API-Key'
}
}
}
})
})Step 15: Deployment
Option 1: Docker
Create a Dockerfile:
FROM oven/bun:1.1-alpine
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
COPY . .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["bun", "run", "src/index.ts"]Build and run:
docker build -t hono-api .
docker run -p 3000:3000 hono-apiOption 2: Fly.io
Create fly.toml:
app = "your-hono-api"
primary_region = "fra"
[build]
builder = "oven/bun:1.1"
[http_service]
internal_port = 3000
force_https = true
[env]
NODE_ENV = "production"Deploy:
fly launch
fly deployOption 3: Cloudflare Workers
Hono works seamlessly with Workers. Update your export:
export default appDeploy with Wrangler:
bunx wrangler deployPerformance Comparison
Here's why Hono + Bun outperforms Express + Node:
| Metric | Express + Node | Hono + Bun |
|---|---|---|
| Requests/sec | ~15,000 | ~90,000 |
| Startup time | ~300ms | ~25ms |
| Memory usage | ~50MB | ~20MB |
| Bundle size | Heavy | ~14KB |
Summary
You've built a complete REST API with:
- ✅ Hono framework for routing and middleware
- ✅ Bun runtime for maximum performance
- ✅ TypeScript for type safety
- ✅ SQLite database with CRUD operations
- ✅ Input validation with Zod
- ✅ Authentication middleware
- ✅ CORS and error handling
- ✅ OpenAPI documentation
- ✅ Deployment options
Next Steps
- Add rate limiting with
hono/rate-limit - Implement JWT authentication
- Add database migrations
- Set up CI/CD pipeline
- Add unit and integration tests
The combination of Hono and Bun represents the future of JavaScript backend development - fast, type-safe, and developer-friendly. Start building!
Resources
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

AI SDK 4.0: New Features and Use Cases
Discover the new features and use cases of AI SDK 4.0, including PDF support, computer use, and more.

Introduction to Model Context Protocol (MCP)
Learn about the Model Context Protocol (MCP), its use cases, advantages, and how to build and use an MCP server with TypeScript.

MCP‑Governed Agentic Automation: How to Ship AI Agents Safely in 2026
A practical blueprint for building AI agents with MCP servers, governance, and workflow automation—plus a safe rollout path for production teams.