webdev.complete
🛜 Building a REST API
🟢The Backend
Lesson 68 of 117
30 min

Express & Hono

Routes, middleware, body parsing, static files.

Node ships http.createServer. Why use a framework at all? Because parsing JSON bodies, matching /users/:idroutes, chaining auth middleware, and serving static files in 6 lines is hard; in 60, it's annoying. Frameworks give you the ergonomics. The two you'll meet today are Express (the OG since 2010) and Hono (the rising star, born 2022). They look similar. Their futures are not.

The mental model both share

Every web framework boils down to three concepts:

  • Router - match incoming requests by method + path to a handler function.
  • Handler - a function that reads the request and writes a response.
  • Middleware - functions that run before (and sometimes after) the handler. Auth checks, logging, body parsing.

A request flows through middleware like an assembly line. Each piece can mutate the request, attach data, short-circuit with a response, or call next() to pass control along.

Express: the 15-year-old workhorse

Express is the most-installed npm package of all time. It runs on top of Node's http module and shaped how Node servers are written. Express 5 finally shipped in October 2024 with async middleware support, fixing a decade-old pain point.

bash
npm init -y
npm install express
npm install -D @types/express tsx typescript
server.ts
import express from "express";

const app = express();

// Middleware: parse JSON bodies into req.body
app.use(express.json());

// Middleware: log every request
app.use((req, _res, next) => {
  console.log(req.method + " " + req.url);
  next();
});

// In-memory store (use a DB in real life)
const users: { id: string; name: string }[] = [];

// GET /users
app.get("/users", (_req, res) => {
  res.json(users);
});

// GET /users/:id
app.get("/users/:id", (req, res) => {
  const user = users.find(u => u.id === req.params.id);
  if (!user) return res.status(404).json({ error: "not found" });
  res.json(user);
});

// POST /users
app.post("/users", (req, res) => {
  const { name } = req.body;
  if (typeof name !== "string") {
    return res.status(400).json({ error: "name is required" });
  }
  const user = { id: crypto.randomUUID(), name };
  users.push(user);
  res.status(201).json(user);
});

// Static files (e.g. ./public/index.html served at /)
app.use(express.static("public"));

app.listen(3000, () => {
  console.log("listening on http://localhost:3000");
});
Express conventions to memorize
req.params = URL path parameters (:id). req.query = query string (?limit=10). req.body = parsed body (only after express.json() middleware). res.json = send JSON + set Content-Type. res.status(201).json(...) = chain status codes.

Hono: built on Web Standards

Hono is the "modern" one. It uses the standard Request and Response objects (the same ones Service Workers and fetchuse), which means the same codebase runs unchanged on Node, Bun, Deno, Cloudflare Workers, AWS Lambda, and Vercel Edge. It's also 10x smaller than Express in bundle size. Currently on version 4 in 2026.

bash
npm install hono
# For Node, you also need the adapter:
npm install @hono/node-server
server.ts
import { Hono } from "hono";
import { logger } from "hono/logger";
import { serveStatic } from "@hono/node-server/serve-static";
import { serve } from "@hono/node-server";

const app = new Hono();

// Middleware: built-in logger
app.use("*", logger());

const users: { id: string; name: string }[] = [];

// GET /users
app.get("/users", (c) => c.json(users));

// GET /users/:id
app.get("/users/:id", (c) => {
  const id = c.req.param("id");
  const user = users.find(u => u.id === id);
  if (!user) return c.json({ error: "not found" }, 404);
  return c.json(user);
});

// POST /users
app.post("/users", async (c) => {
  const body = await c.req.json();
  if (typeof body.name !== "string") {
    return c.json({ error: "name is required" }, 400);
  }
  const user = { id: crypto.randomUUID(), name: body.name };
  users.push(user);
  return c.json(user, 201);
});

// Static files
app.use("/*", serveStatic({ root: "./public" }));

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log("listening on http://localhost:" + info.port);
});
Hono conventions to memorize
Every handler gets a c (context) instead of separate req/res. c.req.param("id") for path params. c.req.query("limit") for query. await c.req.json() for parsed body (no middleware needed). c.json(data, status) returns a Response.

Why Hono's Web Standards angle matters

Look closer at the Hono server. It builds a Request and returns a Response. That's the same fetch API your browser uses. The Node adapter (@hono/node-server) is the only thing that bridges to Node's legacy req/res objects. Drop the adapter and the same code runs on Cloudflare Workers without changes:

ts
// app.ts (shared)
import { Hono } from "hono";
const app = new Hono();
app.get("/", (c) => c.text("hello"));
export default app;

// Cloudflare Worker entrypoint - that's it
export default app;

// Bun
Bun.serve({ fetch: app.fetch });

// Node
serve({ fetch: app.fetch });

One library, every runtime. Express can't do that - it's Node-only.

Middleware chains: the assembly line

Both frameworks let you mount middleware globally, per-path, or per-method. Order matters - middleware runs top to bottom.

ts
// Express
app.use(express.json());                      // for every route
app.use("/admin", requireAdmin);              // only under /admin
app.get("/users", logUsers, fetchUsers);      // multiple per route

// Hono
app.use("*", logger());                        // for every route
app.use("/admin/*", requireAdmin);             // only under /admin
app.get("/users", logUsers, fetchUsers);       // chainable too
The dreaded missing next()
In Express, if a middleware doesn't call next() and doesn't send a response, the request hangs forever. Hono uses async functions that resolve, so it's harder to forget.

Body parsing

Express: nothing parses the body until you opt in with express.json(), express.urlencoded(), or multer for file uploads.

Hono: the body is a Web Request, so methods are built in. await c.req.json(), await c.req.formData(), await c.req.text(), await c.req.arrayBuffer().

Static files

Both let you serve a folder as static assets. Production-grade apps usually offload static to a CDN, but for local dev or small services it's plenty.

  • Express: app.use(express.static("public"))
  • Hono: app.use("/*", serveStatic({ root: "./public" }))

Which should you pick?

  • Express - more tutorials, more StackOverflow, easier to hire for. Pick it for: a team that already knows it, a legacy codebase, or when you want maximum middleware ecosystem.
  • Hono - tiny, type-safe, runs everywhere. Pick it for: new projects, edge deployment, TS-heavy teams, or when you want the same code in dev/prod/edge.

In 2026 my default for a new Node API is Hono. Express is still rock-solid, but it's designed for one runtime in a multi-runtime world.

Quiz

Quiz1 / 4

What does middleware do in Express/Hono?

Recap

  • A framework is a router + handlers + a middleware chain.
  • Express = oldest, biggest ecosystem, Node-only. Express 5 finally has async middleware.
  • Hono = built on Web Standards, runs on every modern runtime, tiny bundle, end-to-end type-safe.
  • Both support routes, params, queries, JSON bodies, middleware, and static files. Hono's ergonomics use a unified c (context).
  • Default pick in 2026: Hono for new projects, Express when the team already lives in it.
Built with Next.js, Tailwind & Sandpack.
Learn. Build. Ship.