Skip to main content

Hono: One Small API That Runs Almost Anywhere

Updated 8 min readMarkdown

A small bookshop wants an API. Customers should be able to list the books and place an order, and the shop owner wants it live this week on a cheap host. Next month she might move it to a Node server or a serverless host that charges per request. Most frameworks are tied to one runtime, so the usual answer is to write the API once and rewrite it when the host changes. The routes and validation stay the same; only the plumbing changes. Hono exists for exactly this situation. It is a tiny web framework that runs the same code on Node, Cloudflare Workers, Deno, and Bun, so the API stays put when the host changes.

What I would check before using it

My judgement: reach for Hono when the API is small and you are not sure where it will run. A bookshop API, a webhook receiver, or a small service in front of a database are the honest cases. The code stays short, and moving from Node to a serverless host means changing the adapter, not the routes. If the API will live on one Node server forever, that portability buys you nothing, and a bigger ecosystem might serve you better.

I would check four things before committing. First, how much you need beyond routing. Hono gives you routes, middleware, and helpers, and little else. There is no built-in database layer, no built-in login, and no official way to render pages. Middleware covers the common needs, logging, CORS, JWT, but you assemble the pieces yourself. Second, whether your handlers use anything tied to one runtime. Code that reads files with Node's fs module or opens a long-lived database connection will not run on Cloudflare Workers, so keep handlers free of runtime-specific calls. Third, what the team already knows. If everyone writes Express, Hono will feel close but not identical: handlers take one context object instead of separate req and res arguments, and that will trip people up for a week. Fourth, the size of the community. Hono's docs are good, but there are far fewer tutorials and answered questions than Express has, so expect to read the docs, not search for a ready-made answer.

The verdict: use Hono for a small, portable API that runs on more than one runtime. Do not use it for a large app that needs a bigger framework's batteries, or for code that leans on Node-only features. If either of those flips, the portability stops paying for itself.

A bookshop API, end to end

The worked example is the shop from the opening. Three books are in stock, and the API needs two routes: one that lists the books and one that takes an order. An order is only accepted when the book exists, the quantity is a whole number of one or more, and enough copies are in stock. Hono is built on web standards rather than on any one runtime: its request and response objects are the same Request and Response from the Fetch API, and its routing is a small, fast layer on top. The project is a plain Node package with two dependencies, installed with npm install hono @hono/node-server. The second package is the adapter that lets Hono run on Node; Hono itself is runtime-neutral, so each runtime needs its own small adapter. Here is the whole server:

import { Hono } from 'hono';
import { serve } from '@hono/node-server';

const app = new Hono();

const BOOKS = [
  { id: 'BK-201', title: 'Learning to Code, Slowly', author: 'Mara Iqbal', price: 24.99, inStock: 14 },
  { id: 'BK-202', title: 'The Quiet Debugger', author: 'Tomás Reyes', price: 31.5, inStock: 6 },
  { id: 'BK-203', title: 'Notes from a Night Shift', author: 'Hana Sato', price: 18.0, inStock: 0 },
];

const ORDERS = [];
let nextOrderId = 5001;

app.get('/books', (c) => {
  return c.json(BOOKS);
});

app.post('/orders', async (c) => {
  const body = await c.req.json();
  const book = BOOKS.find((b) => b.id === body.bookId);
  if (!book) {
    return c.json({ error: 'No book with id ' + body.bookId }, 400);
  }
  if (!Number.isInteger(body.quantity) || body.quantity < 1) {
    return c.json({ error: 'quantity must be a whole number of 1 or more' }, 400);
  }
  if (body.quantity > book.inStock) {
    return c.json({ error: 'Only ' + book.inStock + ' copies of ' + book.title + ' in stock' }, 400);
  }
  const order = {
    id: nextOrderId,
    bookId: book.id,
    title: book.title,
    quantity: body.quantity,
    total: Math.round(book.price * body.quantity * 100) / 100,
    status: 'confirmed',
  };
  nextOrderId += 1;
  book.inStock -= body.quantity;
  ORDERS.push(order);
  return c.json(order, 201);
});

app.get('/orders', (c) => {
  return c.json(ORDERS);
});

serve({ fetch: app.fetch, port: 3000 }, (info) => {
  console.log('Bookshop API listening on http://localhost:' + info.port);
});

The c in each handler is the context: one object that carries the request and builds the response. c.json() turns a value into a JSON response, and the second argument sets the status code. Starting the server and asking for the books gave this real response:

HTTP/1.1 200 OK
[{"id":"BK-201","title":"Learning to Code, Slowly","author":"Mara Iqbal","price":24.99,"inStock":14},{"id":"BK-202","title":"The Quiet Debugger","author":"Tomás Reyes","price":31.5,"inStock":6},{"id":"BK-203","title":"Notes from a Night Shift","author":"Hana Sato","price":18,"inStock":0}]

Placing an order for two copies of the second book returned a 201 with the order, and the total is right: 31.50 times 2 is 63.00.

HTTP/1.1 201 Created
{"id":5001,"bookId":"BK-202","title":"The Quiet Debugger","quantity":2,"total":63,"status":"confirmed"}

The validation works too. An unknown book id gets a 400, and a quantity of zero gets a 400 with a plain message instead of a crash:

HTTP/1.1 400 Bad Request
{"error":"No book with id BK-999"}

HTTP/1.1 400 Bad Request
{"error":"quantity must be a whole number of 1 or more"}

Now the part the tidy tutorials skip: something goes wrong. A natural first version of the orders list route calls c.json() but forgets to return it:

// BUG: the handler calls c.json() but never returns it.
app.get('/orders', (c) => {
  c.json(ORDERS);
});

The route exists, the server starts fine, and the request still fails. The real response from running this version is:

HTTP/1.1 404 Not Found
404 Not Found

The server log shows nothing at all. Hono expects every handler to return a Response, and when the handler returns nothing, Hono treats the request as unmatched and answers with its default 404. The fix is one word: return c.json(ORDERS). That silent 404 is the mistake to remember, because it looks exactly like a wrong URL and sends you checking your routes when the route was right all along. If a Hono route you know exists answers 404, check first whether its handler returns the response.

Common mistakes and how to fix them

1. Forgetting to return the response in a handler. The symptom is a 404 Not Found on a route that exists, with nothing in the server log. The fix is to return the result of c.json(), c.text(), or c.html() from every handler.

2. Forgetting to await the request body. c.req.json() returns a promise, so reading body.bookId without awaiting it gives undefined, and the order is rejected with 400 {"error":"No book with id undefined"}. The fix is const body = await c.req.json() in an async handler.

3. Requesting the wrong path or method. Asking for GET /book when the route is /books returns 404 Not Found, and so does sending a POST to a route defined with app.get. Hono matches the exact path and method. The fix is to check the path and method against the route definitions; a trailing slash makes a different route.

4. Installing Hono but not the Node adapter. Running the server with only hono installed fails at startup with Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@hono/node-server'. Hono itself has no server; each runtime needs an adapter. The fix is npm install @hono/node-server on Node, and the matching adapter elsewhere.

Where this leaves you

Hono makes one clear promise: the same routes run on Node, Cloudflare Workers, Deno, and Bun, because the code targets web standards instead of one runtime. That portability costs you the comforts of a bigger framework, and it only pays off while the API stays small. The habits that matter are small: return every response, await every body, and check the path and method before doubting the framework. Next, take the bookshop API above, run it on your own machine, then move it: swap the Node adapter for another runtime and watch the same routes answer. If they do, you will know whether your next API deserves to be portable or just to stay put.

Further reading

Originally published at https://practicaldevblog.blogspot.com/2026/11/hono-one-small-api-that-runs-almost.html

No comments yet. Be the first to comment.