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:3000

That 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 /todos reads the todo collection.
  • POST /todos creates a new todo in that collection.
  • GET /todos/2 reads one todo.
  • PATCH /todos/2 changes part of one todo.
  • DELETE /todos/2 removes 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 OK for a successful read or update.
  • 201 Created when a POST creates a resource.
  • 400 Bad Request when the input is malformed or fails basic validation.
  • 404 Not Found when the route or resource does not exist.
  • 500 Internal Server Error when 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

+50 XP on completion