1. The Problem
Build a web server using only Node.js's built-in http module — no Express.
Serve static files (HTML, CSS, JS, images) from a public/ directory.
Add custom routes for dynamic responses. Handle 404s, MIME types, and basic request logging.
Why this project? Understanding what a web server actually does — before using a framework — makes every framework make sense. Express, Fastify, Hono are all doing what you'll build here, but with more convenience.
2. How to Think About This
An HTTP server does one thing: receive a request (method + path + headers + body) and send a response (status + headers + body). Every web framework is a library that makes this easier. Your server needs to:
- Parse the URL to find the path
- Check custom routes first (dynamic responses)
- If no route matches, try to serve a file from
public/ - If no file either, respond with 404
MIME types tell the browser what kind of file it's receiving — essential for CSS and JS to be interpreted correctly rather than displayed as plain text.
3. The Build
import { createServer } from "http";
import { readFile, stat } from "fs/promises";
import { extname, join } from "path";
import { URL } from "url";
const PORT = process.env.PORT ?? 3000;
const PUBLIC = join(process.cwd(), "public");
// ── MIME types ─────────────────────────────────────────────────────────────
const MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".txt": "text/plain; charset=utf-8",
};
// ── Helper: send a response ────────────────────────────────────────────────
function send(res, status, body, contentType = "text/html; charset=utf-8") {
const data = typeof body === "string" ? body : JSON.stringify(body, null, 2);
res.writeHead(status, {
"Content-Type": contentType,
"Content-Length": Buffer.byteLength(data),
"X-Powered-By": "The Codex",
});
res.end(data);
}
// ── Middleware: logger ─────────────────────────────────────────────────────
function logger(req, status) {
const ts = new Date().toISOString().slice(11, 19); // HH:MM:SS
console.log(`[${ts}] ${req.method} ${req.url} → ${status}`);
}
// ── Custom routes ──────────────────────────────────────────────────────────
const routes = new Map([
["GET /", (req, res) => {
send(res, 200, `<!DOCTYPE html>
<html><head><title>My Server</title></head>
<body>
<h1>Welcome!</h1>
<p>Built with Node.js — no frameworks.</p>
<ul>
<li><a href="/about">About</a></li>
<li><a href="/api/time">Current time (JSON)</a></li>
</ul>
</body></html>`);
}],
["GET /about", (req, res) => {
send(res, 200, "<h1>About</h1><p>A handbuilt Node.js server.</p>");
}],
["GET /api/time",(req, res) => {
send(res, 200, { time: new Date().toISOString(), uptime: process.uptime() },
"application/json; charset=utf-8");
}],
]);
// ── Request handler ────────────────────────────────────────────────────────
const server = createServer(async (req, res) => {
const { pathname } = new URL(req.url, `http://localhost`);
// 1. Try custom routes
const routeKey = `${req.method} ${pathname}`;
if (routes.has(routeKey)) {
routes.get(routeKey)(req, res);
logger(req, 200);
return;
}
// 2. Try static file
try {
// Prevent directory traversal: ".." in path is rejected
if (pathname.includes("..")) { send(res, 403, "<h1>403 Forbidden</h1>"); return; }
const filePath = join(PUBLIC, pathname === "/" ? "index.html" : pathname);
const fileStat = await stat(filePath);
if (!fileStat.isFile()) { send(res, 404, "<h1>404 Not Found</h1>"); return; }
const data = await readFile(filePath);
const contentType = MIME[extname(filePath)] ?? "application/octet-stream";
res.writeHead(200, { "Content-Type": contentType,
"Content-Length": data.length });
res.end(data);
logger(req, 200);
} catch (e) {
if (e.code === "ENOENT") {
send(res, 404, "<h1>404 Not Found</h1><p>That page doesn't exist.</p>");
logger(req, 404);
} else {
send(res, 500, "<h1>500 Internal Server Error</h1>");
console.error(e);
logger(req, 500);
}
}
});
server.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
console.log(`Serving static files from ./public/`);
console.log("Press Ctrl+C to stop.
");
});
The public directory
mkdir public
echo '<h1>Static page</h1>' > public/static.html4. Test & Prove
// Integration test — starts the real server, makes real HTTP requests
import { createServer } from "http";
// Minimal test harness (no Jest, no Supertest)
async function httpGet(port, path) {
return new Promise((resolve, reject) => {
const options = { hostname: "localhost", port, path, method: "GET" };
const req = require("http").request(options, res => {
let data = "";
res.on("data", c => data += c);
res.on("end", () => resolve({ status: res.statusCode, body: data, headers: res.headers }));
});
req.on("error", reject);
req.end();
});
}
// Alternative: just use fetch (Node.js v18+):
async function testServer() {
const BASE = "http://localhost:3000";
// Run server first: node server.mjs &
console.log("Testing server at", BASE);
console.log("(Make sure node server.mjs is running in another terminal)
");
const tests = [
{ path: "/", expectedStatus: 200, expectedContent: "Welcome" },
{ path: "/about", expectedStatus: 200, expectedContent: "About" },
{ path: "/api/time", expectedStatus: 200, expectedContent: "time" },
{ path: "/missing", expectedStatus: 404, expectedContent: "404" },
];
for (const { path, expectedStatus, expectedContent } of tests) {
try {
const res = await fetch(BASE + path);
const body = await res.text();
const statusOk = res.status === expectedStatus;
const contentOk = body.includes(expectedContent);
console.log(`${statusOk && contentOk ? " ✓" : " ✗"} GET ${path} → ${res.status}`);
} catch (e) {
console.log(` ✗ GET ${path} → ERROR: ${e.message}`);
}
}
}
testServer();
5. The Interface
Input: HTTP requests from any client (browser, curl, fetch).
Output: HTTP responses + request log in the terminal.
[14:30:01] GET / → 200 [14:30:02] GET /about → 200 [14:30:03] GET /api/time → 200 [14:30:04] GET /missing.html → 404
curl http://localhost:3000/api/time
# {"time":"2026-06-27T14:30:03.000Z","uptime":42.1}6. Run It
mkdir public
node server.mjs
# Open http://localhost:3000 in your browser🎯 Try this next
- Add HTTP caching headers (
Cache-Control,ETag) so browsers cache static files - Add gzip compression using Node's built-in
zlibmodule — wrap the response stream - Serve a single-page app: all unknown routes return
index.htmlso client-side routing works - Port to Express.js — notice exactly what boilerplate disappears (routing, MIME types, error handling)
What you practised
What an HTTP server actually does — request routing, response codes, headers, MIME types.
File serving with fs/promises, directory traversal prevention, the
middleware logger pattern. Map-based routing as an alternative to if/else chains.
This is the foundation everything from Express to Next.js is built on.
Reference: Async & Promises · Error Handling · Modules (ESM)