1. The Problem
Build a minimal web framework. Handlers are registered against a URL path, and an incoming request path is dispatched to the matching handler. A known path returns status 200 with the handler's body; an unknown path returns 404.
Why this project? Express, Flask, and every web framework are, at their core, a routing table and a dispatcher. Building the smallest possible version demystifies "how does a URL become my function running?"
2. How to Think About This
Keep a Map of path → handler function. A route(path, handler) method registers one. handle(path) looks the path up: if a handler exists, call it and return [200, body]; if not, return [404, "Not Found"]. That is the entire essence of routing — everything else a real framework adds (methods, params, middleware) builds on this.
3. The Build
export class App {
constructor() {
this.routes = new Map(); // path -> handler
}
route(path, handler) {
this.routes.set(path, handler);
return this; // chainable
}
handle(path) {
const handler = this.routes.get(path);
if (!handler) return [404, "Not Found"];
return [200, handler()];
}
}
const app = new App();
app.route("/", () => "Welcome home!")
.route("/about", () => "About us");
console.log(app.handle("/")); // [ 200, 'Welcome home!' ]
console.log(app.handle("/about")); // [ 200, 'About us' ]
console.log(app.handle("/missing")); // [ 404, 'Not Found' ]
4. Test & Prove
import { App } from "./app.mjs";
let pass = 0, fail = 0;
function test(desc, fn) {
try { fn(); console.log(" \u2713", desc); pass++; }
catch (e) { console.log(" \u2717", desc, "\u2014", e.message); fail++; }
}
function assert(cond, msg) { if (!cond) throw new Error(msg || "assertion failed"); }
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
test("a registered route returns 200 and its body", () => {
const app = new App(); app.route("/hi", () => "hello");
assert(eq(app.handle("/hi"), [200, "hello"]));
});
test("an unknown route returns 404", () => {
assert(eq(new App().handle("/nope"), [404, "Not Found"]));
});
test("each route maps to its own handler", () => {
const app = new App();
app.route("/a", () => "A").route("/b", () => "B");
assert(eq(app.handle("/a"), [200, "A"]) && eq(app.handle("/b"), [200, "B"]));
});
test("re-registering a path overrides the handler", () => {
const app = new App();
app.route("/x", () => "old").route("/x", () => "new");
assert(eq(app.handle("/x"), [200, "new"]));
});
console.log(`\n${pass} passed, ${fail} failed`);
5. The Interface
node app.mjs [ 200, 'Welcome home!' ] [ 200, 'About us' ] [ 404, 'Not Found' ]
6. Run It
node app.mjs
node app.test.mjs🎯 Try this next
- Support HTTP methods — key routes by "GET /path" instead of just path
- Add URL parameters like /user/:id and pass them to the handler
- Add middleware: functions that run before every handler
- Wire it to Node\u2019s http module so it serves real requests