IntermediateTypeScript · Lesson 6 of 9

Building an HTTP Server

Create a JSON API with Node's built-in http module — routes, body parsing, status codes.

Every web framework (Express, Fastify, Next.js) is built on Node's node:http module. Building a small server with it directly teaches you what the frameworks do for you: matching the method and URL, reading the request body, setting headers and status codes.

Use proper status codes: 200 OK, 201 Created, 400 bad input, 404 not found, 500 unexpected error. Always respond with Content-Type: application/json for JSON APIs.

Read the body by collecting the request stream's chunks, then JSON.parse it inside a try/catch — clients can send anything.

server.tsTypeScript
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";

interface Task {
  id: number;
  title: string;
  done: boolean;
}

const tasks: Task[] = [{ id: 1, title: "Learn TypeScript", done: false }];
let nextId = 2;

function send(res: ServerResponse, status: number, body: unknown): void {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(JSON.stringify(body));
}

async function readJson(req: IncomingMessage): Promise<unknown> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(chunk as Buffer);
  return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url ?? "/", "http://localhost");

  try {
    if (req.method === "GET" && url.pathname === "/tasks") {
      return send(res, 200, tasks);
    }

    if (req.method === "POST" && url.pathname === "/tasks") {
      const body = await readJson(req);
      const title = (body as { title?: unknown }).title;
      if (typeof title !== "string" || title.trim() === "") {
        return send(res, 400, { error: "title is required" });
      }
      const task: Task = { id: nextId++, title: title.trim(), done: false };
      tasks.push(task);
      return send(res, 201, task);
    }

    send(res, 404, { error: "Not found" });
  } catch (err) {
    if (err instanceof SyntaxError) return send(res, 400, { error: "Invalid JSON" });
    console.error(err);
    send(res, 500, { error: "Internal server error" });
  }
});

server.listen(3000, () => console.log("Listening on http://localhost:3000"));
TerminalShell
npx tsx server.ts
curl http://localhost:3000/tasks
curl -X POST http://localhost:3000/tasks -H "Content-Type: application/json" -d '{"title":"Build an API"}'

Key points

  • A server is a function from request to response — route on method + path.
  • Validate every request body; never trust client input.
  • Return accurate status codes and log unexpected errors server-side.

Exercise

Add PATCH /tasks/:id to mark a task done and DELETE /tasks/:id to remove it, returning 404 for unknown ids. Then add GET /tasks?done=true filtering using url.searchParams.

Show solution

Try the exercise yourself first — then compare your approach with this one.

Match /tasks/:id with a regular expression to get the id, then look the task up. PATCH marks it done, DELETE removes it, and both return 404 for unknown ids. GET /tasks?done=true filters with url.searchParams.

server.tsTypeScript
import { createServer, type ServerResponse } from "node:http";

interface Task {
  id: number;
  title: string;
  done: boolean;
}

let tasks: Task[] = [
  { id: 1, title: "Learn TypeScript", done: true },
  { id: 2, title: "Build an API", done: false },
];

function send(res: ServerResponse, status: number, body?: unknown): void {
  res.writeHead(status, { "Content-Type": "application/json" });
  res.end(body === undefined ? "" : JSON.stringify(body));
}

const server = createServer((req, res) => {
  const url = new URL(req.url ?? "/", "http://localhost");

  if (req.method === "GET" && url.pathname === "/tasks") {
    const done = url.searchParams.get("done");
    const list = done === null ? tasks : tasks.filter((t) => t.done === (done === "true"));
    return send(res, 200, list);
  }

  const match = url.pathname.match(/^\/tasks\/(\d+)$/);
  if (match) {
    const id = Number(match[1]);
    const task = tasks.find((t) => t.id === id);
    if (!task) return send(res, 404, { error: "Task not found" });

    if (req.method === "PATCH") {
      task.done = true;
      return send(res, 200, task);
    }
    if (req.method === "DELETE") {
      tasks = tasks.filter((t) => t.id !== id);
      return send(res, 204);
    }
  }

  send(res, 404, { error: "Not found" });
});

server.listen(3000, () => console.log("Listening on http://localhost:3000"));
TerminalShell
curl "http://localhost:3000/tasks?done=false"   # [{"id":2,"title":"Build an API","done":false}]
curl -X PATCH http://localhost:3000/tasks/2      # {"id":2,"title":"Build an API","done":true}
curl -X DELETE http://localhost:3000/tasks/1     # (204 No Content)
curl -X PATCH http://localhost:3000/tasks/99     # {"error":"Task not found"}

Check your understanding

  1. Which status code should a successful POST that creates a resource return?

  2. A client sends a body that isn't valid JSON. Which status fits best?

  3. Why wrap JSON.parse of a request body in try/catch?

  4. How do you read ?done=true from a request URL in Node.js?

Ask AI