Build a Small REST API with Hono
Create a quest tracker API in TypeScript with Hono: routes, status codes and input validation, all in one small file.

Quest objectives
- Set up a Hono project on Node.js
- Write routes for reading, creating, updating and deleting
- Answer with the right HTTP status codes
- Validate input instead of trusting it
Hono is a small and fast web framework built on web standards. Its API will feel familiar if you've used Express, and it runs on Node.js, Deno, Bun and edge platforms alike. In this tutorial you build an API for a quest tracker that can create, read, update and delete quests.
Create the project
npm create hono@latest quest-api -- --template nodejs --install --pm npm
cd quest-api
npm run devThe starter runs your code with hot reloading, so the server restarts every time you save. Open src/index.ts and replace its contents. The quests live in a Map to keep this tutorial focused on the API, which means they are gone after a restart.
import { serve } from '@hono/node-server';
import { Hono } from 'hono';
type Quest = { id: number; title: string; done: boolean };
const quests = new Map<number, Quest>();
let nextId = 1;
const app = new Hono();
app.get('/quests', (c) => c.json([...quests.values()]));
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`Listening on http://localhost:${info.port}`);
});Every route handler receives a context, c, which holds the request and helpers for building the response. c.json() sends JSON with the correct content type.
The other routes
Add these above the serve() call:
app.get('/quests/:id', (c) => {
const quest = quests.get(Number(c.req.param('id')));
if (!quest) return c.json({ error: 'Quest not found' }, 404);
return c.json(quest);
});
app.post('/quests', async (c) => {
const body = await c.req.json<{ title?: unknown }>().catch(() => null);
const title = typeof body?.title === 'string' ? body.title.trim() : '';
if (!title) return c.json({ error: 'A title is required' }, 400);
const quest: Quest = { id: nextId++, title, done: false };
quests.set(quest.id, quest);
return c.json(quest, 201);
});
app.patch('/quests/:id', async (c) => {
const quest = quests.get(Number(c.req.param('id')));
if (!quest) return c.json({ error: 'Quest not found' }, 404);
const body = await c.req.json<{ done?: unknown }>().catch(() => null);
if (typeof body?.done !== 'boolean') {
return c.json({ error: '"done" must be true or false' }, 400);
}
quest.done = body.done;
return c.json(quest);
});
app.delete('/quests/:id', (c) => {
const deleted = quests.delete(Number(c.req.param('id')));
if (!deleted) return c.json({ error: 'Quest not found' }, 404);
return c.body(null, 204);
});Status codes are how an API communicates with its clients, so they're worth getting right:
201 Created says that a new quest now exists.
204 No Content says that the delete worked and there is nothing to send back.
400 Bad Request says that the client sent something invalid.
404 Not Found says that this quest doesn't exist.
Try it out
Use curl in a second terminal:
curl -X POST localhost:3000/quests \
-H 'content-type: application/json' \
-d '{"title":"Slay the dragon"}'
# {"id":1,"title":"Slay the dragon","done":false}
curl -X PATCH localhost:3000/quests/1 \
-H 'content-type: application/json' \
-d '{"done":true}'
# {"id":1,"title":"Slay the dragon","done":true}
curl -i -X POST localhost:3000/quests \
-H 'content-type: application/json' \
-d '{}'
# HTTP/1.1 400 Bad Request
# {"error":"A title is required"}Where to go next
The next step is to replace the Map with a real database so that your quests survive a restart. SQLite is a good place to start, since it needs no separate server.