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.

18 min 9 sections

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

ts
// 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'))
bash
node raw-server.ts &
curl -i localhost:3000/health
text
HTTP/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.

bash
npm install hono @hono/node-server
ts
// src/app.ts
import { Hono } from 'hono'

export const app = new Hono()

app.get('/health', (c) => c.json({ status: 'ok' }))
ts
// 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

ts
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)
})
bash
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    # 404

Note 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:

ts
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 route

Middleware runs in the order it's registered, like layers of an onion. Hono ships many ready-made ones:

ts
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 413

A middleware can also short-circuit by returning a response without calling next(), which is how authentication works:

ts
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:

ts
// 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') }))
ts
// 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:

ActionMethod and pathSuccess response
ListGET /orders?status=paid&limit=20200 with an array (or a page object)
Read oneGET /orders/42200, or 404 if it doesn't exist
CreatePOST /orders201 with the created resource and a Location header
ReplacePUT /orders/42200 with the resource
Partial updatePATCH /orders/42200 with the resource
DeleteDELETE /orders/42204 with no body
An action that isn't CRUDPOST /orders/42/cancel200 or 202
  • Nouns in paths, verbs in methods: POST /orders, not POST /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:

ts
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:

  1. GET /bookmarks lists bookmarks, with optional ?tag= filtering and ?limit= (default 20, at most 100) and ?offset=.
  2. POST /bookmarks creates one from { url, title, tags }, returning 201 and a Location header.
  3. GET /bookmarks/:id, DELETE /bookmarks/:id with correct 404 and 204 responses.
  4. Add a middleware that logs method, path, status and duration, and a JSON notFound handler.
  5. Put the routes in src/bookmarks/routes.ts, mount them in src/app.ts, start the server from src/server.ts, and check every endpoint with curl or app.request.
Show solution
ts
// 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),
)
ts
// 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))
ts
// 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}`))
bash
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 c and return a Response built with c.json, c.text or c.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. 1.What does c.req.json<CreateOrder>() guarantee about the request body?

  2. 2.A middleware is registered with app.use before the routes. Its code after await next() runs when?

  3. 3.Which is the conventional response to a successful POST /orders that creates an order?

  4. 4.How does app.request('/orders') help testing?

Finished reading?

Mark it complete to fill in your roadmap.