Manual Routing - No Framework , No Safety Net¶
Table of Contents¶
- Why Build a Router From Scratch
- The Simplest Router : If-Else Hell
- Route Parameters Parsing
- Route Groups and Middleware Pattern
- Why Raw Routing Becomes Unmaintainable
- Security : Route Validation and Path Traversal
Why Build a Router From Scratch¶
Every framework hides routing behind layers of abstraction. When you build one yourself you understand exactly how Express , Fastify , or Koa dispatch requests - there's no magic , just pattern matching and function calls
Also sometimes you cant use a framework. Embedded systems , serverless cold-start optimization , or just stubbornness
The Simplest Router : If-Else Hell¶
const http = require('node:http')
const server = http.createServer((req, res) => {
const { method, url } = req
// this gets ugly fast
if (method === 'GET' && url === '/') {
return res.end('Home')
}
if (method === 'GET' && url === '/users') {
return res.end('User list')
}
if (method === 'GET' && url.match(/^\/users\/(\d+)$/)) {
const id = url.match(/^\/users\/(\d+)$/)[1]
return res.end(`User ${id}`)
}
if (method === 'POST' && url === '/users') {
return res.end('Create user')
}
res.writeHead(404)
res.end('Not found')
})
Works for 5 routes. At 50 routes your editor lags and your brain bleeds
A slightly more structured approach - route table:
const routes = new Map()
// register a route
function addRoute(method, path, handler) {
const key = `${method}:${path}`
routes.set(key, handler)
}
// match and dispatch
function matchRoute(method, url) {
// exact match first
const exactKey = `${method}:${url}`
if (routes.has(exactKey)) return routes.get(exactKey)
// pattern match with params
for (const [key, handler] of routes) {
const [routeMethod, routePath] = key.split(':')
if (routeMethod !== method) continue
const params = extractParams(routePath, url)
if (params !== null) {
return (req, res) => handler(req, res, params)
}
}
return null
}
function extractParams(pattern, url) {
const patternParts = pattern.split('/')
const urlParts = url.split('/')
if (patternParts.length !== urlParts.length) return null
const params = {}
for (let i = 0; i < patternParts.length; i++) {
if (patternParts[i].startsWith(':')) {
params[patternParts[i].slice(1)] = urlParts[i]
} else if (patternParts[i] !== urlParts[i]) {
return null
}
}
return params
}
// usage
addRoute('GET', '/', (req, res) => res.end('Home'))
addRoute('GET', '/users', (req, res) => res.end('Users'))
addRoute('GET', '/users/:id', (req, res, params) => {
res.end(`User ${params.id}`)
})
addRoute('POST', '/users', (req, res) => res.end('Created'))
const server = http.createServer((req, res) => {
const handler = matchRoute(req.method, req.url)
if (handler) return handler(req, res)
res.writeHead(404)
res.end('Not found')
})
Route Parameters Parsing¶
Extracting named parameters from URLs is regex matching with capture groups:
function parseRoutePattern(pattern) {
// convert :param to named capture groups
const paramNames = []
const regexStr = pattern
.replace(/\//g, '\\/')
.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name)
return '([^/]+)'
})
return {
regex: new RegExp(`^${regexStr}$`),
paramNames
}
}
const routeCache = new Map()
function registerRoute(method, path, handler) {
const key = `${method}:${path}`
routeCache.set(key, {
handler,
parsed: parseRoutePattern(path)
})
}
function match(method, url) {
for (const [key, route] of routeCache) {
const [routeMethod] = key.split(':')
if (routeMethod !== method) continue
const match = url.match(route.parsed.regex)
if (match) {
const params = {}
route.parsed.paramNames.forEach((name, i) => {
params[name] = match[i + 1]
})
return { handler: route.handler, params }
}
}
return null
}
registerRoute('GET', '/users/:userId/posts/:postId', (req, res, params) => {
res.end(`User ${params.userId} , Post ${params.postId}`)
})
Query string parameters are separate from route params - access them via URL:
const urlObj = new URL(req.url, `http://${req.headers.host}`)
const page = urlObj.searchParams.get('page') || '1'
Route Groups and Middleware Pattern¶
A mini-middleware system for raw Node.js:
class Router {
constructor() {
this.routes = []
this.middlewares = []
}
use(fn) {
this.middlewares.push(fn)
}
get(path, handler) { this.addRoute('GET', path, handler) }
post(path, handler) { this.addRoute('POST', path, handler) }
put(path, handler) { this.addRoute('PUT', path, handler) }
delete(path, handler) { this.addRoute('DELETE', path, handler) }
addRoute(method, path, handler) {
const paramNames = []
const regexStr = path.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name)
return '([^/]+)'
})
this.routes.push({
method,
regex: new RegExp(`^${regexStr}$`),
paramNames,
handler
})
}
async handle(req, res) {
// run middleware stack
let idx = 0
const next = async () => {
if (idx < this.middlewares.length) {
const mw = this.middlewares[idx++]
await mw(req, res, next)
}
}
await next()
// if middleware already ended the request , stop
if (res.writableEnded) return
// match route
const urlObj = new URL(req.url, `http://${req.headers.host}`)
const pathname = urlObj.pathname
for (const route of this.routes) {
if (route.method !== req.method) continue
const match = pathname.match(route.regex)
if (match) {
const params = {}
route.paramNames.forEach((name, i) => {
params[name] = match[i + 1]
})
req.params = params
req.query = Object.fromEntries(urlObj.searchParams)
return route.handler(req, res)
}
}
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ error: 'Route not found' }))
}
}
// usage
const router = new Router()
// logging middleware
router.use(async (req, res, next) => {
const start = Date.now()
await next()
const ms = Date.now() - start
console.log(`${req.method} ${req.url} - ${ms}ms`)
})
// auth middleware (example)
router.use(async (req, res, next) => {
if (req.url.startsWith('/admin') && req.headers['x-api-key'] !== 'secret') {
res.writeHead(401)
return res.end('{"error": "unauthorized"}')
}
await next()
})
router.get('/', (req, res) => res.end('Home'))
router.get('/users/:id', (req, res) => {
res.end(`User ${req.params.id} - page ${req.query.page || 1}`)
})
const server = http.createServer((req, res) => router.handle(req, res))
server.listen(3000)
Why Raw Routing Becomes Unmaintainable¶
Your route table starts clean. Two weeks later you have:
- Route collisions -
/users/:idvs/users/mewhere:idmatches "me" first - Regex debugging from hell -
path.match(/^\/(\d+)\/([a-z]+)$/)with no comments - Missing edge cases - OPTIONS requests for CORS , HEAD requests ,
*catch-all - No built-in validation -
:idmatches "abc" just as easily as "42" - Performance - linear scanning 200+ route patterns per request hurts at scale
This is why Express exists. But knowing how to build one means you understand Express
Express routes internally the same way - regex compilation , param extraction , linear scan. The difference is battle-testing , edge case handling , and decades of fixes
Security : Route Validation and Path Traversal¶
Path traversal happens when route parameters become file paths:
// VULNERABLE
router.get('/files/:filename', (req, res) => {
const content = fs.readFileSync(`/var/data/${req.params.filename}`)
res.end(content)
})
An attacker hits /files/../../../etc/passwd and now they read your password file
Mitigation:
const path = require('node:path')
router.get('/files/:filename', (req, res) => {
const safePath = path.resolve('/var/data/', req.params.filename)
// ensure resolved path stays inside the base directory
if (!safePath.startsWith(path.resolve('/var/data/'))) {
res.writeHead(403)
return res.end('Path traversal detected - nice try')
}
if (!fs.existsSync(safePath)) {
res.writeHead(404)
return res.end('File not found')
}
const content = fs.readFileSync(safePath)
res.end(content)
})
Route param validation - never trust :id without checking:
function requireInt(name) {
return (req, res, next) => {
const val = parseInt(req.params[name], 10)
if (isNaN(val) || String(val) !== req.params[name]) {
res.writeHead(400)
return res.end(`{"error": "${name} must be an integer"}`)
}
req.params[name] = val
next()
}
}
router.get('/users/:id', requireInt('id'), (req, res) => {
// req.params.id is now guaranteed integer
res.end(`User ${req.params.id}`)
})
prerequisites¶
web_02_https.md - HTTPS setup , certificate management
next => web_04_middleware.md