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

Noqta Team
By Noqta Team ·

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 Receive GitLab Comments on WhatsApp Using Webhooks.

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