Build a blog API in 5 minutes
A step-by-step guide from zero to a typed, documented, testable Hono API.
Scaffold
Generate a ready-to-run project with the CLI, install dependencies, and start the dev server.
bunx hono-forge init blog-api cd blog-api bun install bun run dev
Create a service
Write an injectable service that holds business logic and state.
// src/post.service.ts import { Injectable } from 'hono-forge' @Injectable() export class PostService { private posts = [ { id: 1, title: 'Hello hono-forge' }, ] list() { return this.posts } get(id: number) { return this.posts.find((p) => p.id === id) } create(title: string) { const post = { id: this.posts.length + 1, title } this.posts.push(post) return post } }
Create a controller
Map HTTP verbs to methods and validate the body with Zod.
// src/post.controller.ts import { Controller, Get, Post, Body, Param, Injectable, Public } from 'hono-forge' import { z } from 'zod' import { PostService } from './post.service' const CreatePostSchema = z.object({ title: z.string().min(1) }) @Controller('/posts') @Injectable([PostService]) export class PostController { constructor(private postService: PostService) {} @Get() @Public() list() { return this.postService.list() } @Get('/:id') getOne(c: any) { return this.postService.get(Number(Param(c, 'id'))) } @Post() async create(c: any) { const dto = await Body(c, CreatePostSchema) return this.postService.create(dto.title) } }
Wire the app
Export the app from a module. OpenAPI docs and Scalar are generated automatically.
// src/app.module.ts import { Module, createApp } from 'hono-forge' import { PostController } from './post.controller' @Module({ controllers: [PostController] }) class AppModule {} export default await createApp(AppModule, { openapi: true })
Run & test
Start the server, open /docs, run the included tests, and call endpoints with curl or api-tester.
bun test # GET http://localhost:3000/posts โ 200 # POST http://localhost:3000/posts โ 201 (requires auth if guarded) # GET http://localhost:3000/docs โ Scalar UI
Typed client
Generate a typed route map so your frontend gets autocomplete and compile-time API safety.
bunx hono-forge client ./src/app.module.ts --out ./routes.gen.ts # import type { AppRoutes } from './routes.gen' # const api = createClient<AppRoutes>('http://localhost:3000')
Todo app backend, step by step
A complete, copy-pasteable backend: service with DI, validated CRUD routes, auth guards, OpenAPI docs, tests, and curl commands to prove it works.
The service
An in-memory store behind an @Injectable @Singleton โ every injected consumer shares the same instance.
// src/todo.service.ts import { Injectable, Singleton } from 'hono-forge' export interface Todo { id: number title: string done: boolean createdAt: string } @Injectable() @Singleton() export class TodoService { private todos: Todo[] = [] private nextId = 1 list(filter?: { done?: boolean }) { if (filter?.done === undefined) return this.todos return this.todos.filter((t) => t.done === filter.done) } get(id: number) { return this.todos.find((t) => t.id === id) } create(title: string) { const todo: Todo = { id: this.nextId++, title, done: false, createdAt: new Date().toISOString(), } this.todos.push(todo) return todo } update(id: number, data: { title?: string; done?: boolean }) { const todo = this.get(id) if (!todo) return undefined Object.assign(todo, data) return todo } remove(id: number) { const idx = this.todos.findIndex((t) => t.id === id) if (idx === -1) return false this.todos.splice(idx, 1) return true } }
The controller
Full CRUD. @Validate declares each input schema once โ it validates before the handler runs AND feeds the OpenAPI spec. Reads are @Public, writes need auth. HttpException.notFound maps missing rows to a structured 404.
// src/todo.controller.ts import type { Context } from 'hono' import { Controller, Get, Post, Patch, Delete, Injectable, Validate, Status, NoContent, Body, Params, Query, HttpException, Public, RequireAuth, } from 'hono-forge' import { z } from 'zod' import { TodoService } from './todo.service' const CreateTodoSchema = z.object({ title: z.string().min(1).max(200), }) const UpdateTodoSchema = z.object({ title: z.string().min(1).max(200).optional(), done: z.boolean().optional(), }) const ListQuerySchema = z.object({ done: z.coerce.boolean().optional(), }) const IdParamSchema = z.object({ id: z.coerce.number().int() }) @Controller('/todos') @Injectable([TodoService]) export class TodoController { constructor(private todos: TodoService) {} @Get() @Public() @Validate({ query: ListQuerySchema }) list(c: Context) { const { done } = Query<{ done?: boolean }>(c) return this.todos.list({ done }) } @Get('/:id') @Public() @Validate({ params: IdParamSchema }) getOne(c: Context) { const { id } = Params<{ id: number }>(c) const todo = this.todos.get(id) if (!todo) throw HttpException.notFound('Todo not found') return todo } @Post() @RequireAuth() @Status(201) @Validate({ body: CreateTodoSchema }) async create(c: Context) { const body = await Body<z.infer<typeof CreateTodoSchema>>(c) return this.todos.create(body.title) } @Patch('/:id') @RequireAuth() @Validate({ params: IdParamSchema, body: UpdateTodoSchema }) async update(c: Context) { const { id } = Params<{ id: number }>(c) const body = await Body<z.infer<typeof UpdateTodoSchema>>(c) const todo = this.todos.update(id, body) if (!todo) throw HttpException.notFound('Todo not found') return todo } @Delete('/:id') @RequireAuth() @NoContent() @Validate({ params: IdParamSchema }) remove(c: Context) { const { id } = Params<{ id: number }>(c) if (!this.todos.remove(id)) throw HttpException.notFound('Todo not found') } }
Module, guard & bootstrap
A demo guardExecutor so guarded routes have an auth decision, then createApp wires health, metrics, OpenAPI docs, and graceful shutdown in one call.
// src/app.module.ts import { Module, createApp, HonoRouteBuilder, HttpException, } from 'hono-forge' import { TodoController } from './todo.controller' import { TodoService } from './todo.service' // Demo guard โ "Authorization: Bearer dev-token" passes, anything else โ 401. // Swap this for real JWT verification / session lookup in production. HonoRouteBuilder.configure({ guardExecutor: async (c) => { if (c.req.header('authorization') !== 'Bearer dev-token') { throw HttpException.unauthorized('Missing or bad token') } c.set('user', { id: 'dev', roles: ['admin'] }) return true }, }) @Module({ controllers: [TodoController], providers: [TodoService], }) class AppModule {} export default await createApp(AppModule, { openapi: { info: { title: 'Todo API', version: '1.0.0' } }, health: true, // GET /health metrics: true, // GET /metrics (Prometheus) printRoutes: true, // route table at startup shutdown: true, // graceful SIGINT/SIGTERM })
Test it
createTestingModule builds an isolated app โ no port needed. The test covers auth rejection and the full CRUD cycle.
// src/todo.test.ts import { describe, expect, it } from 'bun:test' import './app.module' // applies the guardExecutor config import { createTestingModule } from 'hono-forge' import { TodoController } from './todo.controller' const mod = createTestingModule({ controllers: [TodoController] }).compile() const app = mod.createApp() describe('todo api', () => { it('rejects unauthenticated writes', async () => { const res = await app.fetch(new Request('http://t/todos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ title: 'x' }), })) expect(res.status).toBe(401) }) it('creates, reads, updates and deletes a todo', async () => { const auth = { 'content-type': 'application/json', authorization: 'Bearer dev-token', } const created = await app.fetch(new Request('http://t/todos', { method: 'POST', headers: auth, body: JSON.stringify({ title: 'write docs' }), })) expect(created.status).toBe(201) const todo = await created.json() const one = await app.fetch(new Request(`http://t/todos/${todo.id}`)) expect(one.status).toBe(200) const patched = await app.fetch(new Request(`http://t/todos/${todo.id}`, { method: 'PATCH', headers: auth, body: JSON.stringify({ done: true }), })) expect(patched.status).toBe(200) const removed = await app.fetch(new Request(`http://t/todos/${todo.id}`, { method: 'DELETE', headers: auth, })) expect(removed.status).toBe(204) }) }) mod.cleanup()
Run it
Everything is exercised with curl โ reads are public, writes send the dev-token Bearer header.
bun run dev # public reads curl http://localhost:3000/todos curl "http://localhost:3000/todos?done=false" # guarded writes โ Bearer dev-token (see guardExecutor) curl -X POST http://localhost:3000/todos \ -H "authorization: Bearer dev-token" \ -H "content-type: application/json" \ -d '{"title":"ship the docs"}' # โ 201 { "id": 1, "title": "ship the docs", "done": false, ... } curl -X PATCH http://localhost:3000/todos/1 \ -H "authorization: Bearer dev-token" \ -H "content-type: application/json" \ -d '{"done":true}' curl -X DELETE http://localhost:3000/todos/1 \ -H "authorization: Bearer dev-token" # โ 204 No Content curl http://localhost:3000/docs # Scalar UI curl http://localhost:3000/openapi.json # generated spec