NodeJSBasic Routing (No Framework)

Basic Routing (No Framework)

Routing is deciding which code handles a given request, based on its method and path. Frameworks make this a one-liner (app.get('/users/:id', …)), but building a router by hand reveals what that one-liner actually does — match a method, match a path (possibly with parameters), extract those parameters, and dispatch. This page builds a small router on raw http, from naive to genuinely usable.

The naive approach: if/else

JS
import { createServer } from 'node:http'

createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)
  const { pathname } = url

  if (req.method === 'GET' && pathname === '/') {
    res.end('Home')
  } else if (req.method === 'GET' && pathname === '/about') {
    res.end('About')
  } else {
    res.writeHead(404).end('Not Found')
  }
}).listen(3000)
if/else routing collapses under its own weight
This works for three routes and becomes unmanageable at thirty: every route repeats the method+path check, there's no way to capture path parameters like `/users/42`, and a forgotten `return`/`end` silently breaks a branch. The structure doesn't scale — which is exactly why routers (and frameworks) exist.
A route table

The first improvement: store routes as data — a map of "METHOD path" to handler — and look up the incoming request:

JS
const routes = {
  'GET /':        (req, res) => res.end('Home'),
  'GET /about':   (req, res) => res.end('About'),
  'POST /users':  (req, res) => res.end('Created'),
}

createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)
  const key = `${req.method} ${url.pathname}`
  const handler = routes[key] || ((_req, r) => r.writeHead(404).end('Not Found'))
  handler(req, res)
}).listen(3000)
A lookup table separates routing from handling
Keying handlers by `"METHOD path"` turns a tangle of conditionals into a clean dispatch. But it only matches **exact, static** paths — `/users/42` won't match `/users/:id` because the id varies. For dynamic segments we need pattern matching.
Dynamic routes with parameters

Real APIs have paths like /users/42 where 42 is data. We register patterns with named segments and match incoming paths against them, extracting the values — exactly what req.params is in a framework:

A small pattern-matching router

JS
const routes = []

function on(method, pattern, handler) {
  // Turn '/users/:id' into /^\/users\/([^/]+)$/ and remember param names
  const names = []
  const regex = new RegExp(
    '^' + pattern.replace(/:([^/]+)/g, (_, name) => {
      names.push(name)
      return '([^/]+)'
    }) + '$',
  )
  routes.push({ method, regex, names, handler })
}

function match(method, pathname) {
  for (const route of routes) {
    if (route.method !== method) continue
    const m = pathname.match(route.regex)
    if (m) {
      const params = {}
      route.names.forEach((name, i) => (params[name] = m[i + 1]))
      return { handler: route.handler, params }
    }
  }
  return null
}

// Register routes:
on('GET', '/users/:id', (req, res, params) => {
  res.end(`user ${params.id}`)          // GET /users/42 → 'user 42'
})

Wiring the router into the server

JS
import { createServer } from 'node:http'

createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host}`)
  const found = match(req.method, url.pathname)

  if (found) {
    found.handler(req, res, found.params)
  } else {
    res.writeHead(404, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify({ error: 'Not Found' }))
  }
}).listen(3000)
This is essentially what Express does
Express compiles route strings like `/users/:id` into regular expressions and populates `req.params` from the captured groups — the same technique shown here, plus middleware, query parsing, and edge cases (optional params, wildcards). Seeing the mechanism means [Express routing](/nodejs/express-routing) will hold no mystery.
Common routing concerns
  • Trailing slashes/about and /about/ are different strings; normalize or redirect if you want them equivalent.

  • Method vs path mismatch — path exists but wrong method → respond 405 with an Allow header, not 404.

  • Query strings — strip them before matching (match on url.pathname, read params from url.searchParams).

  • Order/specificity — match more specific routes before catch-alls; first match wins in the loop above.

Match on `pathname`, not the raw `req.url`
`req.url` includes the query string (`/search?q=node`), so matching it directly against `/search` fails. Always parse with `new URL(...)` and route on `url.pathname`, reading parameters from `url.searchParams` separately. Forgetting this makes routes mysteriously miss whenever a query string is present.
When to stop hand-rolling

This router is fine for a handful of endpoints or for learning. Once you need middleware, body parsing, nested routers, error handling, and parameter validation, re-implementing all of it is wasted effort — that's the moment to adopt Express, which provides exactly these on top of the same http module.

Next
Send actual web pages and assets to the browser: [Serving HTML & Static Files](/nodejs/serving-html).