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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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 higher

Step 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/bun

Your 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.ts

Visit 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.ts

Step 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 users

Don't forget to install Zod for validation:

bun add zod @hono/zod-validator

Step 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 products

Step 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 routes

Step 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.ts

Test 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-ui

Add 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-api

Option 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 deploy

Option 3: Cloudflare Workers

Hono works seamlessly with Workers. Update your export:

export default app

Deploy with Wrangler:

bunx wrangler deploy

Performance Comparison

Here's why Hono + Bun outperforms Express + Node:

MetricExpress + NodeHono + Bun
Requests/sec~15,000~90,000
Startup time~300ms~25ms
Memory usage~50MB~20MB
Bundle sizeHeavy~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


Want to read more tutorials? Check out our latest tutorial on MCP‑Governed Agentic Automation: How to Ship AI Agents Safely in 2026.

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