Your first Node server, no framework
Build an HTTP server with nothing but Node's built-in http module, then see exactly which parts of that work Express will do for you.

Before you install a framework, write the thing it replaces. It's about fifteen lines, and once you've seen them, every Express app you touch afterward will make a lot more sense.
The smallest server that works
Node ships an http module in the standard library. No install, no dependency, just node server.js:
import http from "node:http";
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Hello from Node\n");
});
server.listen(3000, () => {
console.log("Listening on http://localhost:3000");
});Run it, visit http://localhost:3000, and you'll see the text. http.createServer takes a callback that runs once per incoming request, with a request object (req) and a response object (res) you fill in and send back. res.writeHead sets the status code and headers, res.end writes the body and closes the response. Miss that last call and the browser just sits there waiting forever, because nothing ever told it the response was done.
That callback is the entire surface area of a raw Node server. Everything else, routing, parsing bodies, handling different content types, is something you build by hand or a framework builds for you.
Routing by hand
Say you want two different behaviors for two different URLs. req.url and req.method are just strings and you branch on them yourself:
import http from "node:http";
const server = http.createServer((req, res) => {
if (req.method === "GET" && req.url === "/links") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ data: [] }));
return;
}
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Linkstash, the hard way\n");
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "NotFound" }));
});
server.listen(3000);That's already getting ugly, and it's only two routes. Real routing needs to match path segments (/links/42 should extract 42), handle query strings, and fall through to a 404 for anything unmatched. You could keep writing if chains, but nobody does past their second route, because a router is exactly the kind of repetitive, easy-to-get-wrong code a library should own.
Reading a request body
Here's the part that surprises people coming from other languages: Node doesn't hand you a parsed body. req is a readable stream, and the bytes arrive in chunks over time, so you collect them yourself:
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on("data", (chunk) => chunks.push(chunk));
req.on("end", () => resolve(Buffer.concat(chunks).toString()));
req.on("error", reject);
});
}
const server = http.createServer(async (req, res) => {
if (req.method === "POST" && req.url === "/links") {
const raw = await readBody(req);
const body = JSON.parse(raw);
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: 1, ...body }));
return;
}
// ...
});Streams exist for a good reason. A request body could be a 500MB file upload, and Node doesn't want to buffer that in memory before your code even sees it start. But for a JSON API, you almost always want the whole body before you do anything, so you end up writing this same readBody helper (or something like it) in every raw-Node project that accepts POST requests.
Why this matters even though you won't write it again
You're about to install Express and never call http.createServer directly again in this series. That's fine, that's the point. But when you see express.json() in the next lesson, you'll know it's doing exactly the chunk-collecting, JSON-parsing dance above, and you'll know what breaks if you forget to include it.
What happens when something throws
One more thing worth seeing before you move on. Change the POST handler above so JSON.parse(raw) gets a body that isn't valid JSON, say an empty string. JSON.parse("") throws a SyntaxError, and inside an async callback passed to http.createServer, Node does not automatically catch that for you the way Express 5 will. The rejection has nowhere defined to go, and depending on your Node version and how the callback is wired up, that can crash the whole process rather than just failing the one request.
Try it yourself: send a POST to /links with curl -X POST -d 'not json' http://localhost:3000/links and watch the server log an unhandled rejection instead of returning a clean 400. Every other client connected to that server drops too, because there's only one process and it just died. That's not a Node flaw, it's just what "no framework" means: nobody is standing between your code and the raw event loop, catching what you forgot to catch.
const server = http.createServer(async (req, res) => {
try {
if (req.method === "POST" && req.url === "/links") {
const raw = await readBody(req);
const body = JSON.parse(raw); // can throw
res.writeHead(201, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: 1, ...body }));
return;
}
} catch {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "ValidationError" }));
}
});Wrap every handler in try/catch by hand, remember to do it every single time, and you've reinvented a small piece of what a framework's error handling gives you for free. Keep that feeling in mind. Lesson 7 in this series comes back to exactly this problem, once Linkstash is big enough that "did I remember the try/catch" stops being something you can eyeball.
What you just felt
Three things got tedious fast: matching routes by hand, parsing bodies from a raw stream, and building up response headers one call at a time. None of that is hard, exactly, it's just the same handful of chores every server needs, rewritten slightly differently by every developer who hasn't reached for a framework yet.
That's the gap Express fills. It doesn't replace http.createServer, it sits on top of it: an Express app is still, underneath, a callback handed to http.createServer. What changes is everything around that callback, the routing table, the body parsing, the response helpers, gets written once, well, and shared.
Quick check
In raw Node, why do you need to collect chunks with req.on('data', ...) instead of just reading req.body?
If you've worked through the TypeScript basics post, keep that lesson in mind. The rest of this series writes Linkstash in TypeScript, and the types you already know (interfaces, string | undefined, optional properties) map directly onto request bodies and query parameters.
Next: Express 5: your first routes, where the fifteen lines above become about four.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


