# 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.

Backend with Node.js roadmap · Building APIs · 18 min read · https://levelcode.org/roadmaps/node-backend/http-servers-node

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:

| 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`, 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`.

**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`.

## Quiz

1. What does c.req.json<CreateOrder>() guarantee about the request body?

   A. That it matches CreateOrder, or Hono returns 400
   B. Nothing about its shape: the type argument is a compile-time claim, so you still need runtime validation
   C. That it's a JSON object, not an array
   D. That required fields are present

   **Answer: B.** Type arguments don't exist at runtime. Clients can send any JSON, so validate it with a schema before trusting it.

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

   A. Before the handler
   B. Only if the handler throws
   C. Never
   D. After the handler (and later middleware) have produced the response

   **Answer: D.** Middleware wraps the rest of the chain. Code after await next() sees the finished response, which is how timing and logging middleware work.

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

   A. 200 with no body
   B. 204 No Content
   C. 201 Created, with the new resource and a Location header
   D. 302 redirect to /orders

   **Answer: C.** 201 signals creation; returning the resource and its URL saves the client a follow-up request.

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

   A. It calls the app in-process with a Request and returns its Response, with no server or port
   B. It sends a real HTTP request to localhost
   C. It mocks the database
   D. It only works in the browser

   **Answer: A.** A Hono app is a function from Request to Response, so tests can exercise routes and middleware directly and quickly.
