Skip to content

Manual Routing - No Framework , No Safety Net

Table of Contents


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/:id vs /users/me where :id matches "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 - :id matches "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