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
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)A route table
The first improvement: store routes as data — a map of "METHOD path" to handler — and look up the incoming request:
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)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
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
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)Common routing concerns
Trailing slashes —
/aboutand/about/are different strings; normalize or redirect if you want them equivalent.Method vs path mismatch — path exists but wrong method → respond
405with anAllowheader, not404.Query strings — strip them before matching (match on
url.pathname, read params fromurl.searchParams).Order/specificity — match more specific routes before catch-alls; first match wins in the loop above.
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.