Step 05 of 12
HTTP servers with Hono
Build a JSON API on Node: the raw http module, then Hono's routing, params, middleware and route groups, plus REST conventions for resources and status codes.
An HTTP server's job is simple to describe: read a request (method, path, headers, body), decide what to do, and write a response (status, headers, body). This lesson shows that job done with nothing but Node's node:http module, then builds a proper JSON API with Hono, a small, fast, TypeScript-first web framework built on the same Request and Response standards the browser uses. Along the way it covers routing, parameters, middleware, route groups and the REST conventions that make an API predictable.
A server with only node:http
// raw-server.ts
import { createServer } from 'node:http'
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ status: 'ok' }))
return
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'not found' }))
})
server.listen(3000, () => console.log('listening on http://localhost:3000'))node raw-server.ts &
curl -i localhost:3000/healthHTTP/1.1 200 OK
Content-Type: application/json
...
{"status":"ok"}Everything a framework does is built on this callback. But look at what's missing: matching /orders/42 and extracting 42, parsing query strings, reading a JSON body (which arrives as a stream of chunks), setting headers consistently, handling errors, sharing logic such as authentication across routes. A framework handles those so you write only your application's logic.
Hono basics
Node has several mature frameworks: Express (the oldest and most widely deployed), Fastify (fast, schema-driven) and Hono (small, modern, built on web-standard Request and Response, excellent TypeScript types). This roadmap uses Hono; the ideas transfer directly to the others.
npm install hono @hono/node-server// src/app.ts
import { Hono } from 'hono'
export const app = new Hono()
app.get('/health', (c) => c.json({ status: 'ok' }))// src/server.ts
import { serve } from '@hono/node-server'
import { app } from './app.ts'
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`listening on http://localhost:${info.port}`)
})Each handler receives a context c, which wraps the request (c.req) and has helpers to build the response: c.json(data, status?), c.text(), c.body(), c.redirect(), c.header(). A handler returns a Response; Hono is built on the same fetch-style API as browsers and edge runtimes, which is why app.fetch is the whole integration with Node.
Routes, params, query and body
type Order = { id: number; customer: string; items: string[]; status: 'pending' | 'paid' }
const orders = new Map<number, Order>()
let nextId = 1
app.get('/orders', (c) => {
const status = c.req.query('status') // ?status=paid, or undefined
const all = [...orders.values()]
return c.json(status ? all.filter((o) => o.status === status) : all)
})
app.get('/orders/:id', (c) => {
const id = Number(c.req.param('id')) // path params are always strings
const order = orders.get(id)
if (!order) return c.json({ error: `order ${id} not found` }, 404)
return c.json(order)
})
app.post('/orders', async (c) => {
const body = await c.req.json<{ customer: string; items: string[] }>()
const order: Order = { id: nextId++, customer: body.customer, items: body.items, status: 'pending' }
orders.set(order.id, order)
return c.json(order, 201, { Location: `/orders/${order.id}` })
})
app.delete('/orders/:id', (c) => {
const deleted = orders.delete(Number(c.req.param('id')))
return deleted ? c.body(null, 204) : c.json({ error: 'not found' }, 404)
})curl -s -X POST localhost:3000/orders -H 'Content-Type: application/json' \
-d '{"customer":"ana","items":["pen"]}'
# {"id":1,"customer":"ana","items":["pen"],"status":"pending"}
curl -s 'localhost:3000/orders?status=pending'
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/orders/99 # 404Note the type argument in c.req.json<...>(): it's a claim, not a check. A client can send anything. The next lesson validates bodies properly with Zod; until then, treat this code as a sketch.
Middleware
Middleware is code that runs around your handlers, for every request or for a group of routes. In Hono, a middleware receives the context and a next function; code before await next() runs on the way in, code after it on the way out:
import type { MiddlewareHandler } from 'hono'
const timing: MiddlewareHandler = async (c, next) => {
const start = performance.now()
await next() // run the rest of the chain, including the handler
const ms = (performance.now() - start).toFixed(1)
c.header('Server-Timing', `app;dur=${ms}`)
console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${ms}ms`)
}
app.use(timing) // every routeMiddleware runs in the order it's registered, like layers of an onion. Hono ships many ready-made ones:
import { cors } from 'hono/cors'
import { requestId } from 'hono/request-id'
import { secureHeaders } from 'hono/secure-headers'
import { bodyLimit } from 'hono/body-limit'
app.use(requestId()) // X-Request-Id on every response, c.get('requestId')
app.use(secureHeaders()) // nosniff, HSTS, frame protection...
app.use('/api/*', cors({ origin: 'https://app.example.com', credentials: true }))
app.use('/api/*', bodyLimit({ maxSize: 1024 * 1024 })) // reject bodies over 1 MB with 413A middleware can also short-circuit by returning a response without calling next(), which is how authentication works:
const requireApiKey: MiddlewareHandler = async (c, next) => {
if (c.req.header('X-API-Key') !== process.env.API_KEY) {
return c.json({ error: 'unauthorized' }, 401)
}
await next()
}Middleware can pass data to handlers with c.set('user', user) and c.get('user'); declaring the variable's type on the Hono instance (new Hono<{ Variables: { user: User } }>()) makes c.get fully typed.
Grouping routes by feature
Put each feature's routes in its own module and mount it under a prefix with app.route:
// src/orders/routes.ts
import { Hono } from 'hono'
export const ordersRoutes = new Hono()
ordersRoutes.get('/', (c) => c.json([]))
ordersRoutes.get('/:id', (c) => c.json({ id: c.req.param('id') }))// src/app.ts
import { Hono } from 'hono'
import { ordersRoutes } from './orders/routes.ts'
export const app = new Hono()
app.route('/orders', ordersRoutes) // GET /orders, GET /orders/:id
app.notFound((c) => c.json({ error: `no route for ${c.req.method} ${c.req.path}` }, 404))app.notFound replaces the default plain-text 404 with a JSON one, so clients always get the same error shape.
REST conventions
An API is easier to use, and harder to misuse, when it follows conventions clients already know:
| Action | Method and path | Success response |
|---|---|---|
| List | GET /orders?status=paid&limit=20 | 200 with an array (or a page object) |
| Read one | GET /orders/42 | 200, or 404 if it doesn't exist |
| Create | POST /orders | 201 with the created resource and a Location header |
| Replace | PUT /orders/42 | 200 with the resource |
| Partial update | PATCH /orders/42 | 200 with the resource |
| Delete | DELETE /orders/42 | 204 with no body |
| An action that isn't CRUD | POST /orders/42/cancel | 200 or 202 |
- Nouns in paths, verbs in methods:
POST /orders, notPOST /createOrder. - GET never changes anything. Browsers, caches and crawlers assume it's safe to repeat.
- Use status codes precisely: 400 for malformed input, 401 not authenticated, 403 authenticated but not allowed, 404 not found, 409 conflict (duplicate email, wrong state), 422 valid JSON that fails validation rules, 500 for your bugs.
- Paginate every list from day one. An endpoint that returns all rows works until the table has a million of them.
- One error shape everywhere, such as
{ "error": { "code": "not_found", "message": "..." } }, so clients handle errors in one place.
Testing routes without a server
Because a Hono app is just a function from Request to Response, you can call it directly, without opening a port:
const res = await app.request('/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer: 'ana', items: ['pen'] }),
})
console.log(res.status, await res.json()) // 201 { id: 1, ... }This makes route tests fast and simple; the testing lesson builds on it.
Practice
Build a small "bookmarks" API with Hono:
GET /bookmarkslists bookmarks, with optional?tag=filtering and?limit=(default 20, at most 100) and?offset=.POST /bookmarkscreates one from{ url, title, tags }, returning 201 and aLocationheader.GET /bookmarks/:id,DELETE /bookmarks/:idwith correct 404 and 204 responses.- Add a middleware that logs method, path, status and duration, and a JSON
notFoundhandler. - Put the routes in
src/bookmarks/routes.ts, mount them insrc/app.ts, start the server fromsrc/server.ts, and check every endpoint withcurlorapp.request.
Show solution
// src/bookmarks/routes.ts
import { Hono } from 'hono'
type Bookmark = { id: number; url: string; title: string; tags: string[]; createdAt: string }
const bookmarks = new Map<number, Bookmark>()
let nextId = 1
export const bookmarkRoutes = new Hono()
bookmarkRoutes.get('/', (c) => {
const tag = c.req.query('tag')
const limit = Math.min(Math.max(Number(c.req.query('limit') ?? 20) || 20, 1), 100)
const offset = Math.max(Number(c.req.query('offset') ?? 0) || 0, 0)
const matching = [...bookmarks.values()].filter((b) => !tag || b.tags.includes(tag))
return c.json({ items: matching.slice(offset, offset + limit), total: matching.length })
})
bookmarkRoutes.post('/', async (c) => {
const body = await c.req.json<{ url: string; title: string; tags?: string[] }>()
const bookmark: Bookmark = {
id: nextId++,
url: body.url,
title: body.title,
tags: body.tags ?? [],
createdAt: new Date().toISOString(),
}
bookmarks.set(bookmark.id, bookmark)
return c.json(bookmark, 201, { Location: `/bookmarks/${bookmark.id}` })
})
bookmarkRoutes.get('/:id', (c) => {
const bookmark = bookmarks.get(Number(c.req.param('id')))
return bookmark ? c.json(bookmark) : c.json({ error: 'bookmark not found' }, 404)
})
bookmarkRoutes.delete('/:id', (c) =>
bookmarks.delete(Number(c.req.param('id'))) ? c.body(null, 204) : c.json({ error: 'bookmark not found' }, 404),
)// src/app.ts
import { Hono } from 'hono'
import type { MiddlewareHandler } from 'hono'
import { bookmarkRoutes } from './bookmarks/routes.ts'
const accessLog: MiddlewareHandler = async (c, next) => {
const start = performance.now()
await next()
console.log(`${c.req.method} ${c.req.path} ${c.res.status} ${(performance.now() - start).toFixed(1)}ms`)
}
export const app = new Hono()
app.use(accessLog)
app.route('/bookmarks', bookmarkRoutes)
app.notFound((c) => c.json({ error: `no route for ${c.req.method} ${c.req.path}` }, 404))// src/server.ts
import { serve } from '@hono/node-server'
import { app } from './app.ts'
serve({ fetch: app.fetch, port: 3000 }, (info) => console.log(`listening on http://localhost:${info.port}`))curl -si -X POST localhost:3000/bookmarks -H 'Content-Type: application/json' \
-d '{"url":"https://nodejs.org","title":"Node","tags":["js"]}' | grep -iE '^HTTP|^location'
# HTTP/1.1 201 Created
# location: /bookmarks/1
curl -s 'localhost:3000/bookmarks?tag=js&limit=500' # limit is clamped to 100
curl -s -o /dev/null -w '%{http_code}\n' -X DELETE localhost:3000/bookmarks/1 # 204
curl -s localhost:3000/nope # {"error":"no route for GET /nope"}Number(x) || 20 falls back to the default when a query value isn't a number (NaN is falsy), and Math.min/Math.max clamp it into range. That works, but it's exactly the kind of hand-written parsing the next lesson replaces with a Zod schema that validates and converts query strings, bodies and params in one place. Header names are case-insensitive in HTTP, and Node sends the ones from a Response in lowercase, which is why curl prints location. Returning { items, total } instead of a bare array leaves room to add pagination fields later without breaking clients.
Key takeaways
- Every Node web framework sits on the same request callback as
node:http; frameworks add routing, parsing, middleware and error handling. - Hono handlers receive a context
cand return aResponsebuilt withc.json,c.textorc.body;serve({ fetch: app.fetch })runs it on Node. - Middleware wraps handlers in registration order, can short-circuit (authentication) and pass data with
c.set/c.get. - Group routes per feature with
app.route, and return JSON for 404s so every error has the same shape. - Follow REST conventions: nouns in paths, safe GETs, precise status codes, pagination for every list; test routes in-process with
app.request.
Check yourself
Quiz
0/4 correct · 4 left
1.What does c.req.json<CreateOrder>() guarantee about the request body?
2.A middleware is registered with app.use before the routes. Its code after await next() runs when?
3.Which is the conventional response to a successful POST /orders that creates an order?
4.How does app.request('/orders') help testing?
Finished reading?
Mark it complete to fill in your roadmap.