hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses HTTP from scratch

What is HTTP

  • The request-response model
  • Anatomy of an HTTP request
  • Anatomy of an HTTP response

Methods

  • GET and HEAD
  • POST
  • PUT, PATCH, and DELETE
  • OPTIONS and CORS preflight

Status codes

  • 2xx success
  • 3xx redirection
  • 4xx client errors
  • 5xx server errors

Headers

  • Request headers
  • Response headers
  • Custom headers

The body

  • JSON
  • Form data and multipart
  • No body

Connections

  • TCP, DNS, and TLS
  • HTTP/1.1 vs HTTP/2
  • Cookies and state

Putting it all together

  • Building a server from scratch
  • From scratch to framework

Building a server from scratch

No framework, just Node.js

Every course in this series uses Hectoday HTTP to build servers. Hectoday HTTP handles routing, body parsing, validation, and error handling for you. But before you use any framework, it is worth building a server without one. Not because you will do this in production, but because it shows you exactly what a framework does behind the scenes. When something goes wrong later, you will understand what is actually happening.

We are going to build a working HTTP server using only Node.js built-in modules. No npm packages. No routing library. No middleware. Just node:http.

The simplest possible server

Code along
import { createServer } from "node:http";

const server = createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello, World!");
});

server.listen(3000, () => {
  console.log("Server running on port 3000");
});

Let’s walk through every line.

createServer takes a callback function that runs for every incoming request. Every single one, regardless of method or path. req is the incoming request object. It contains the method, URL, headers, and body. res is the outgoing response object. You use it to set the status code, headers, and body.

res.writeHead(200, { "Content-Type": "text/plain" }) sets the status code to 200 and the Content-Type header to text/plain.

res.end("Hello, World!") sends the body and closes the connection.

server.listen(3000) tells the server to start listening for connections on port 3000.

Right now, every request gets the exact same response: “Hello, World!” It does not matter if you send GET /books or DELETE /users/42. Same response. There is no routing.

Adding routing manually

const server = createServer((req, res) => {
  const url = new URL(req.url!, `http://${req.headers.host}`);

  if (req.method === "GET" && url.pathname === "/books") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(
      JSON.stringify([
        { id: "book-1", title: "Kindred" },
        { id: "book-2", title: "Ficciones" },
      ]),
    );
    return;
  }

  if (req.method === "GET" && url.pathname.startsWith("/books/")) {
    const id = url.pathname.split("/")[2];
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ id, title: "Kindred" }));
    return;
  }

  // No route matched
  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not Found" }));
});

This is routing from scratch. We check req.method and url.pathname with if statements. We parse the URL to extract path segments (url.pathname.split("/")[2] gets the book ID from /books/book-1). If nothing matches, we return 404.

This works, but look at how much code it takes for just two routes. Now imagine you have fifty routes. The if-else chain becomes enormous, and extracting path parameters with string splitting is fragile and error-prone.

Parsing the request body

Reading the body is where things get really tedious. In Node.js, the request body arrives as a stream of chunks, not all at once. You have to collect those chunks and assemble the full body yourself:

if (req.method === "POST" && url.pathname === "/books") {
  let body = "";
  req.on("data", (chunk) => {
    body += chunk.toString();
  });

  req.on("end", () => {
    try {
      const data = JSON.parse(body);
      // ... create the book
      res.writeHead(201, {
        "Content-Type": "application/json",
        Location: `/books/${newId}`,
      });
      res.end(JSON.stringify({ id: newId, ...data }));
    } catch {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "Invalid JSON" }));
    }
  });
  return;
}

Look at what is happening here. You listen for data events and concatenate each chunk into a string. When the end event fires, the full body is ready. Then you call JSON.parse and wrap it in a try-catch because the body might not be valid JSON. That is six lines of boilerplate for every single POST route. And we have not even checked the Content-Type header yet.

Reading headers

const contentType = req.headers["content-type"];
const authorization = req.headers["authorization"];
const cookie = req.headers["cookie"];
const accept = req.headers["accept"];

One thing to know: Node.js lowercases all header names. In raw HTTP, the header is Content-Type, but in Node.js you access it as content-type. This is actually helpful because it removes case-sensitivity issues.

Setting response headers

res.writeHead(200, {
  "Content-Type": "application/json",
  "Cache-Control": "public, max-age=60",
  "X-Request-Id": crypto.randomUUID(),
});

writeHead sets the status code and all headers at once. You can also use res.setHeader("name", "value") to set headers one at a time before calling res.end().

The full server

Here is the complete working server with GET, POST, and 404 handling:

Code along
import { createServer } from "node:http";

const books = [
  { id: "book-1", title: "Kindred", genre: "science-fiction" },
  { id: "book-2", title: "Ficciones", genre: "fiction" },
];

const server = createServer(async (req, res) => {
  const url = new URL(req.url!, `http://${req.headers.host}`);

  // GET /books
  if (req.method === "GET" && url.pathname === "/books") {
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(books));
    return;
  }

  // GET /books/:id
  if (req.method === "GET" && url.pathname.startsWith("/books/")) {
    const id = url.pathname.split("/")[2];
    const book = books.find((b) => b.id === id);
    if (!book) {
      res.writeHead(404, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "Not found" }));
      return;
    }
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify(book));
    return;
  }

  // POST /books
  if (req.method === "POST" && url.pathname === "/books") {
    let body = "";
    for await (const chunk of req) body += chunk;
    try {
      const data = JSON.parse(body);
      const newBook = { id: `book-${books.length + 1}`, ...data };
      books.push(newBook);
      res.writeHead(201, { "Content-Type": "application/json", Location: `/books/${newBook.id}` });
      res.end(JSON.stringify(newBook));
    } catch {
      res.writeHead(400, { "Content-Type": "application/json" });
      res.end(JSON.stringify({ error: "Invalid JSON" }));
    }
    return;
  }

  // 404
  res.writeHead(404, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ error: "Not Found" }));
});

server.listen(3000, () => console.log("Server on port 3000"));

This server works. You can test it with curl: GET /books returns the list, GET /books/book-1 returns a specific book, POST /books creates a new one, and anything else returns 404.

But look at the code. It is verbose, repetitive, and every route manually parses URLs, reads bodies, sets headers, and handles errors. And this is only three routes. Imagine adding authentication, validation, CORS, rate limiting, and error handling on top. The boilerplate would dwarf the actual business logic.

That is exactly the problem frameworks solve. The next lesson shows what Hectoday HTTP does to eliminate all of this.

Exercises

Exercise 1: Build the server above. Test it with curl: GET /books, GET /books/book-1, POST /books with a JSON body, and GET /nonexistent.

Exercise 2: Add a DELETE /books/:id route. Handle the case where the book does not exist (return 404).

Exercise 3: Add error handling: wrap the entire request handler in a try-catch. Return 500 with a generic message for unexpected errors.

What is the most tedious part of building an HTTP server without a framework?

← Cookies and state From scratch to framework →

© 2026 hectoday. All rights reserved.