Building an HTTP API
Advanced · 20 min read · ▶ live playground · ✦ checkpoint
In the last lesson, your Node programs were short scripts: run, read some inputs, print a result, exit. An HTTP API is a different kind of process. It starts once, keeps listening, and runs a little bit of JavaScript every time a request arrives.
That sounds bigger than it is. At the bottom, an API server receives a request object, decides what it means, and writes a response object. Frameworks make that pleasant, but the magic is smaller than it looks.
A server from scratch with node:http
Node ships an HTTP server in the built-in node:http module. No framework. No npm package. Just a runtime API that can listen on a port and call your function for each request.
Save this as api-server.mjs:
import { createServer } from "node:http";
const todos = [{ id: 1, title: "Learn node:http", done: false }];
let nextId = 2;
function sendJson(res, statusCode, value) {
const body = JSON.stringify(value);
res.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body),
});
res.end(body);
}
async function readJson(req) {
let text = "";
for await (const chunk of req) {
text += chunk;
}
try {
return { ok: true, value: text ? JSON.parse(text) : {} };
} catch {
return { ok: false, error: "Invalid JSON" };
}
}
function validateTodoInput(input) {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return { ok: false, error: "JSON body must be an object" };
}
if (typeof input.title !== "string" || input.title.trim() === "") {
return { ok: false, error: "title is required" };
}
return { ok: true, title: input.title.trim() };
}
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
try {
if (req.method === "GET" && url.pathname === "/todos") {
sendJson(res, 200, todos);
return;
}
if (req.method === "POST" && url.pathname === "/todos") {
const parsed = await readJson(req);
if (!parsed.ok) {
sendJson(res, 400, { error: parsed.error });
return;
}
const valid = validateTodoInput(parsed.value);
if (!valid.ok) {
sendJson(res, 400, { error: valid.error });
return;
}
const todo = { id: nextId, title: valid.title, done: false };
nextId += 1;
todos.push(todo);
sendJson(res, 201, todo);
return;
}
sendJson(res, 404, { error: "Not found" });
} catch {
sendJson(res, 500, { error: "Internal Server Error" });
}
});
server.listen(3000, () => {
console.log("API listening on http://localhost:3000");
});Run it in one terminal:
node api-server.mjs
# API listening on http://localhost:3000That process keeps running until you stop it with Ctrl+C. In another terminal, send requests:
curl http://localhost:3000/todos
# [{"id":1,"title":"Learn node:http","done":false}]
curl -X POST http://localhost:3000/todos \
-H "content-type: application/json" \
-d '{"title":"Read request bodies"}'
# {"id":2,"title":"Read request bodies","done":false}
curl http://localhost:3000/not-here
# {"error":"Not found"}Look at what the server did by hand. req.method gave you the HTTP verb. new URL(req.url, "http://localhost") turned the request path into a URL you can inspect. The request body arrived as a stream, so readJson(req) collected chunks before parsing JSON. res.writeHead(...) chose the status and headers. res.end(...) sent the body.
That is the no-magic version of an HTTP API: route, parse, validate, respond.
Frameworks package the repeated work
The raw server is useful because it shows the machinery. It is also repetitive. Every endpoint needs route matching, JSON parsing, error handling, response helpers, and shared checks.
That is why many Node projects use a web framework or library. You will see names like Express and Fastify. They are not different languages and they are not replacements for HTTP. They package common server chores behind smaller route handlers.
An Express version has the same API shape, but routing and JSON responses are less manual:
import express from "express";
const app = express();
const todos = [{ id: 1, title: "Learn Express routes", done: false }];
let nextId = 2;
app.use(express.json());
app.get("/todos", (req, res) => {
res.json(todos);
});
app.post("/todos", (req, res) => {
if (typeof req.body !== "object" || req.body === null || Array.isArray(req.body)) {
res.status(400).json({ error: "JSON body must be an object" });
return;
}
if (typeof req.body.title !== "string" || req.body.title.trim() === "") {
res.status(400).json({ error: "title is required" });
return;
}
const todo = { id: nextId, title: req.body.title.trim(), done: false };
nextId += 1;
todos.push(todo);
res.status(201).json(todo);
});
app.use((req, res) => {
res.status(404).json({ error: "Not found" });
});
app.listen(3000, () => {
console.log("API listening on http://localhost:3000");
});app.get(...) and app.post(...) are routes. express.json() is middleware: a shared layer that runs before routes and parses JSON request bodies. res.status(...).json(...) combines the response status, JSON serialization, and content-type header.
Fastify has a different style, but the same server ideas are still visible:
import Fastify from "fastify";
const app = Fastify();
const todos = [{ id: 1, title: "Learn Fastify routes", done: false }];
let nextId = 2;
app.get("/todos", async () => {
return todos;
});
app.post("/todos", async (request, reply) => {
const title = request.body?.title;
if (typeof title !== "string" || title.trim() === "") {
return reply.code(400).send({ error: "title is required" });
}
const todo = { id: nextId, title: title.trim(), done: false };
nextId += 1;
todos.push(todo);
return reply.code(201).send(todo);
});
app.setNotFoundHandler((request, reply) => {
reply.code(404).send({ error: "Not found" });
});
await app.listen({ port: 3000 });Express calls its shared layers middleware. Fastify often uses hooks and plugins for shared behavior. Do not get stuck on the naming yet. The beginner model is enough: frameworks give you route registration, request helpers, response helpers, JSON handling, and places to put shared behavior around many routes.
REST: resources, verbs, status codes
REST is a design style for HTTP APIs where URLs point at resources and HTTP verbs describe the action. A resource is usually a noun: /todos, /users, /orders/42.
For a todo API, the common shape is:
GET /todosreads the todo collection.POST /todoscreates a new todo in that collection.GET /todos/2reads one todo.PATCH /todos/2changes part of one todo.DELETE /todos/2removes one todo.
Notice the URL stays noun-shaped. You do not need /getTodos or /createTodo because the verb already carries that meaning. The path says what thing. The method says what action.
Status codes are the short verdicts clients use before reading the body:
200 OKfor a successful read or update.201 Createdwhen aPOSTcreates a resource.400 Bad Requestwhen the input is malformed or fails basic validation.404 Not Foundwhen the route or resource does not exist.500 Internal Server Errorwhen the server failed unexpectedly.
The response body can explain the status in normal JSON:
{
"error": "title is required"
}Good APIs are boring in the best way. The same mistake gets the same status and the same response shape every time.
Validation belongs at the boundary
Every request body is outside data. Treat it like form input, file contents, or network JSON from Part IV: check it before trusting it.
Here is the validation rule from the server, separated so the behavior is easy to see:
function validateTodoInput(input) {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
return { ok: false, error: "JSON body must be an object" };
}
if (typeof input.title !== "string" || input.title.trim() === "") {
return { ok: false, error: "title is required" };
}
return { ok: true, title: input.title.trim() };
}
const cases = [{}, { title: "" }, { title: " Read request bodies " }];
for (const input of cases) {
const result = validateTodoInput(input);
console.log(result.ok ? result.title : result.error);
}→ title is required, then title is required, then Read request bodies.
This is the same boundary rule you have used all course: messy data comes in, validation checks it once, and the inside of your program gets cleaner facts. Frameworks can help organize validation, and some teams use schema libraries for larger APIs. The rule does not change: never let raw request bodies leak deep into the app.
Right now the todos live in memory, which means restarting the server erases them. Next lesson, you will move server data into a database and learn the one database safety rule that matters before anything else: user input does not get pasted into SQL strings.
Checkpoint
Answer all three to mark this lesson complete