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!