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