The Problem with Giant If-Else Chains
In Post #1 we handled routes with simple if (method === 'GET' && url === '/') checks. That works fine for two or three routes. But as your app grows, that handler function becomes a 200-line monstrosity — hard to read, harder to maintain.
Express solves this with app.get('/todos/:id', handler). Today we'll build exactly that — a clean, reusable router.js module — using only the built-in url module and vanilla JavaScript.
/todos), dynamic parameters (/todos/:id), query strings (?done=true), and a clean registration API identical in style to Express.
How URL Parsing Works
Every URL has up to three pieces we care about: the pathname, the query string, and any dynamic segments. Node's built-in url module (or the global URL class) splits these apart for us.
const { URL } = require('url');
// Parse a full URL
const parsed = new URL('http://localhost:3000/todos/42?done=false');
console.log(parsed.pathname); // /todos/42
console.log(parsed.searchParams.get('done')); // false
// For relative paths in a request, provide a dummy base:
function parseUrl(req) {
return new URL(req.url, 'http://localhost');
}
The URL class is available globally in Node.js v10+ — no require needed in modern Node. But requiring it explicitly is fine and makes dependencies obvious.
Pattern Matching: Static vs Dynamic Routes
The tricky part is matching a registered pattern like /todos/:id against a real URL like /todos/42. We need to:
- Split both strings by
/ - Compare segment by segment
- Treat segments starting with
:as wildcards and capture their values
// router.js
function matchRoute(pattern, pathname) {
const patternParts = pattern.split('/').filter(Boolean);
const pathParts = pathname.split('/').filter(Boolean);
if (patternParts.length !== pathParts.length) return null;
const params = {};
for (let i = 0; i < patternParts.length; i++) {
const p = patternParts[i];
const v = pathParts[i];
if (p.startsWith(':')) {
// Dynamic segment — capture the value
params[p.slice(1)] = decodeURIComponent(v);
} else if (p !== v) {
// Static segment mismatch — no match
return null;
}
}
return params; // { id: '42' } or {} for static routes
}
filter(Boolean)? Splitting /todos/42 by / gives ['', 'todos', '42']. The leading empty string would mess up segment comparison. filter(Boolean) removes all falsy values.
Building the Router Class
Now let's wrap the matching logic into a clean Router class with Express-style registration:
// router.js
'use strict';
class Router {
constructor() {
this.routes = [];
}
// Register a route: method is 'GET', 'POST', etc. or '*' for any
add(method, pattern, handler) {
this.routes.push({ method: method.toUpperCase(), pattern, handler });
}
// Shorthand helpers
get(pattern, handler) { this.add('GET', pattern, handler); }
post(pattern, handler) { this.add('POST', pattern, handler); }
put(pattern, handler) { this.add('PUT', pattern, handler); }
delete(pattern, handler) { this.add('DELETE', pattern, handler); }
// Call this from http.createServer
handle(req, res) {
const parsed = new URL(req.url, 'http://localhost');
const pathname = parsed.pathname;
const query = Object.fromEntries(parsed.searchParams);
for (const route of this.routes) {
if (route.method !== req.method && route.method !== '*') continue;
const params = matchRoute(route.pattern, pathname);
if (params === null) continue;
// Attach parsed data to req for easy access in handlers
req.params = params;
req.query = query;
route.handler(req, res);
return; // First match wins — just like Express
}
// No route matched → 404
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Route not found', path: pathname }));
}
}
function matchRoute(pattern, pathname) {
const patternParts = pattern.split('/').filter(Boolean);
const pathParts = pathname.split('/').filter(Boolean);
// Special case: both empty = root '/'
if (patternParts.length === 0 && pathParts.length === 0) return {};
if (patternParts.length !== pathParts.length) return null;
const params = {};
for (let i = 0; i < patternParts.length; i++) {
const p = patternParts[i];
const v = pathParts[i];
if (p.startsWith(':')) {
params[p.slice(1)] = decodeURIComponent(v);
} else if (p !== v) {
return null;
}
}
return params;
}
module.exports = Router;
Using the Router in server.js
Now our server becomes clean and readable:
// server.js
const http = require('http');
const Router = require('./router');
const router = new Router();
// Static route
router.get('/', (req, res) => {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Welcome to Pure Node.js Todo</h1>');
});
// Route with URL parameter
router.get('/todos/:id', (req, res) => {
const { id } = req.params;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id, todo: `Todo #${id}` }));
});
// Route with query string: GET /todos?done=true
router.get('/todos', (req, res) => {
const { done } = req.query; // 'true', 'false', or undefined
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ filter: done ?? 'all', todos: [] }));
});
// POST route
router.post('/todos', (req, res) => {
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Created!' }));
});
// DELETE with parameter
router.delete('/todos/:id', (req, res) => {
const { id } = req.params;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ deleted: id }));
});
const server = http.createServer((req, res) => router.handle(req, res));
server.listen(3000, () => console.log('http://localhost:3000'));
Testing the Routes
Fire up the server and test with curl:
# Static route
curl http://localhost:3000/
# With URL parameter
curl http://localhost:3000/todos/42
# → {"id":"42","todo":"Todo #42"}
# With query string
curl "http://localhost:3000/todos?done=true"
# → {"filter":"true","todos":[]}
# POST
curl -X POST http://localhost:3000/todos
# → {"message":"Created!"}
# DELETE
curl -X DELETE http://localhost:3000/todos/42
# → {"deleted":"42"}
# 404 — no matching route
curl http://localhost:3000/nonexistent
# → {"error":"Route not found","path":"/nonexistent"}
Supporting Multiple Parameters
Our router already supports multiple dynamic segments — no changes needed:
// Works out of the box:
router.get('/users/:userId/todos/:todoId', (req, res) => {
const { userId, todoId } = req.params;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ userId, todoId }));
});
// curl http://localhost:3000/users/5/todos/99
// → {"userId":"5","todoId":"99"}
Adding Middleware Support
Express's killer feature is middleware. We can add basic middleware support — functions that run before handlers:
class Router {
constructor() {
this.routes = [];
this.middlewares = [];
}
use(fn) {
this.middlewares.push(fn);
}
handle(req, res) {
// Run all middlewares first (synchronous for simplicity)
for (const mw of this.middlewares) {
mw(req, res);
}
// ... rest of routing logic
}
}
// Usage: request logger middleware
router.use((req, res) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
});
404 vs 405: Method Not Allowed
There's an important distinction: if a path exists but the method doesn't, you should return 405 Method Not Allowed, not 404. Let's improve the fallback:
handle(req, res) {
const parsed = new URL(req.url, 'http://localhost');
const pathname = parsed.pathname;
const query = Object.fromEntries(parsed.searchParams);
// Check if path exists with any method
const pathExists = this.routes.some(r => matchRoute(r.pattern, pathname) !== null);
for (const route of this.routes) {
if (route.method !== req.method) continue;
const params = matchRoute(route.pattern, pathname);
if (params === null) continue;
req.params = params;
req.query = query;
route.handler(req, res);
return;
}
if (pathExists) {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Method not allowed' }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found', path: pathname }));
}
}
- Use the built-in
URLclass to split pathname from query string - Pattern matching: split by
/, compare segments, capture:params - A Router class with
.get()/.post()/.delete()gives you Express-style ergonomics - First-match-wins is the standard routing strategy
- Distinguish
404 Not Foundfrom405 Method Not Allowed
What's Next
In Post #3, we'll serve real HTML, CSS, and JavaScript files from disk — with proper MIME types, caching headers, and protection against directory traversal attacks.
/static/* that match any path starting with /static/. Hint: check if the pattern ends with /* and handle it as a prefix match.