Series: Pure Node.js — Zero Dependencies

Post #2: Manual Routing — No Express Needed

📅 March 2026 ⏱ 9 min read 🏷 Node.js, Routing, URL

📚 Pure Node.js Series — Zero Dependencies

#1: Build an HTTP Server From Scratch → #2: Manual Routing — No Express Needed (you are here) #3: Serving Static Files (HTML, CSS, JS) #4: Handling Forms & POST Requests #5: JSON Files as a Database #6: HTML Templating with String Interpolation #7: Vanilla JS Charts & Dynamic UI #8: WebSockets From Scratch #9: Chat Rooms & Broadcast Messages #10: Sessions & Cookies Without Packages #11: File Uploads & Image Serving #12: Deploy Pure Node.js to a VPS

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.

What we're building: A router that supports static paths (/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:

  1. Split both strings by /
  2. Compare segment by segment
  3. 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
}
Why 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}`);
});
Production tip: For async middleware (e.g., auth checks that hit a database), you'd implement a next() chain similar to Express. We'll cover async patterns when we add sessions in Post #10.

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 }));
  }
}
Key takeaways:
  • Use the built-in URL class 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 Found from 405 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.

Challenge: Extend the router to support wildcard routes like /static/* that match any path starting with /static/. Hint: check if the pattern ends with /* and handle it as a prefix match.

Building along? Share your code on X/Twitter or GitHub.