Episode 3 — NodeJS MongoDB Backend Architecture / 3.2 — Creating Server

3.2.d — Routing in HTTP Servers

Routing is the process of mapping incoming request URLs and methods to specific handler functions that generate the appropriate response.

Navigation: ← 3.2 Overview | Next → 3.2.e HTTP Status Codes


1. What is Routing?

When a server receives a request, it needs to decide what to do based on two things:

  1. The URL path — where the client wants to go (/, /about, /api/users).
  2. The HTTP method — what the client wants to do (GET, POST, PUT, DELETE).

Routing is the system that connects these inputs to the correct code.

Incoming Request            Router              Handler Function
─────────────────          ────────             ────────────────
GET /                   →  match "/" GET     →  sendHomepage()
GET /about              →  match "/about"    →  sendAboutPage()
GET /api/users          →  match "/api/users"→  getAllUsers()
POST /api/users         →  match "/api/users"→  createUser()
GET /xyz                →  no match          →  send404()

Without routing

If you did not have routing, every request would receive the same response — clearly not useful for a real application.

// This sends the same response for EVERY request
const server = http.createServer((req, res) => {
  res.end('Hello World');  // Same response for /about, /users, /anything
});

With routing

Different URLs produce different responses.

const server = http.createServer((req, res) => {
  if (req.url === '/') {
    res.end('Home Page');
  } else if (req.url === '/about') {
    res.end('About Page');
  } else if (req.url === '/contact') {
    res.end('Contact Page');
  } else {
    res.writeHead(404);
    res.end('Page Not Found');
  }
});

2. Basic Routing with if/else on req.url

The simplest form of routing uses conditional statements.

const http = require('http');

const server = http.createServer((req, res) => {
  // Set default Content-Type
  res.setHeader('Content-Type', 'text/html; charset=utf-8');

  if (req.url === '/') {
    res.writeHead(200);
    res.end('<h1>Home Page</h1><p>Welcome to our website!</p>');
  }
  else if (req.url === '/about') {
    res.writeHead(200);
    res.end('<h1>About Us</h1><p>We are a Node.js learning platform.</p>');
  }
  else if (req.url === '/services') {
    res.writeHead(200);
    res.end('<h1>Our Services</h1><ul><li>Web Development</li><li>API Design</li></ul>');
  }
  else if (req.url === '/contact') {
    res.writeHead(200);
    res.end('<h1>Contact Us</h1><p>Email: hello@example.com</p>');
  }
  else {
    res.writeHead(404);
    res.end('<h1>404 — Page Not Found</h1><a href="/">Go Home</a>');
  }
});

server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Problem with this approach

This works for simple sites but has issues:

  • The if/else chain grows long and hard to read.
  • It does not distinguish between GET and POST on the same URL.
  • Query strings cause matches to fail: req.url for /about?ref=google is "/about?ref=google", not "/about".
  • No support for URL parameters like /users/42.

3. Method-Based Routing: GET vs POST Handling

Real applications need to handle different HTTP methods on the same URL.

const http = require('http');

const server = http.createServer((req, res) => {
  const { method, url } = req;

  // ─── HTML Form Page ───
  if (url === '/form' && method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(`
      <h1>Contact Form</h1>
      <form method="POST" action="/form">
        <label>Name: <input type="text" name="name"></label><br><br>
        <label>Email: <input type="email" name="email"></label><br><br>
        <button type="submit">Submit</button>
      </form>
    `);
  }

  // ─── Handle Form Submission ───
  else if (url === '/form' && method === 'POST') {
    let body = '';

    req.on('data', (chunk) => {
      body += chunk.toString();
    });

    req.on('end', () => {
      console.log('Form data received:', body);
      // body = "name=Alice&email=alice%40example.com"

      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
      res.end(`<h1>Thank you!</h1><p>We received your data.</p><a href="/form">Back to form</a>`);
    });
  }

  // ─── API: GET all users ───
  else if (url === '/api/users' && method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ users: ['Alice', 'Bob', 'Charlie'] }));
  }

  // ─── API: CREATE a user ───
  else if (url === '/api/users' && method === 'POST') {
    let body = '';
    req.on('data', (chunk) => { body += chunk.toString(); });
    req.on('end', () => {
      try {
        const newUser = JSON.parse(body);
        console.log('New user:', newUser);
        res.writeHead(201, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ success: true, user: newUser }));
      } catch (e) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ error: 'Invalid JSON in request body' }));
      }
    });
  }

  // ─── 404 Catch-all ───
  else {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: `Cannot ${method} ${url}` }));
  }
});

server.listen(3000, () => {
  console.log('Server with method routing on http://localhost:3000');
});

Testing method-based routes

# GET the form page
curl http://localhost:3000/form

# POST to the form
curl -X POST http://localhost:3000/form -d "name=Alice&email=alice@example.com"

# GET all users (JSON API)
curl http://localhost:3000/api/users

# POST a new user (JSON API)
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Diana","email":"diana@example.com"}'

4. Parsing URL Parameters Manually

Many routes need dynamic segments: /users/42, /products/laptop, /posts/2024/hello-world.

Extracting the parameter from the URL

const http = require('http');

const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' },
  { id: 3, name: 'Charlie', email: 'charlie@example.com' }
];

const server = http.createServer((req, res) => {
  res.setHeader('Content-Type', 'application/json');

  // GET /api/users — list all users
  if (req.url === '/api/users' && req.method === 'GET') {
    res.writeHead(200);
    res.end(JSON.stringify({ users }));
  }

  // GET /api/users/:id — get a specific user
  else if (req.url.startsWith('/api/users/') && req.method === 'GET') {
    // Extract the ID from the URL
    const parts = req.url.split('/');
    // parts = ['', 'api', 'users', '42']
    const id = parseInt(parts[3]);

    if (isNaN(id)) {
      res.writeHead(400);
      res.end(JSON.stringify({ error: 'Invalid user ID — must be a number' }));
      return;
    }

    const user = users.find(u => u.id === id);

    if (user) {
      res.writeHead(200);
      res.end(JSON.stringify({ user }));
    } else {
      res.writeHead(404);
      res.end(JSON.stringify({ error: `User with ID ${id} not found` }));
    }
  }

  else {
    res.writeHead(404);
    res.end(JSON.stringify({ error: 'Route not found' }));
  }
});

server.listen(3000, () => {
  console.log('Server running on http://localhost:3000');
});

Using regex for pattern matching

For more complex URL patterns, regular expressions help:

// Match /api/users/:id where :id is one or more digits
const userByIdPattern = /^\/api\/users\/(\d+)$/;

const match = req.url.match(userByIdPattern);
if (match) {
  const userId = parseInt(match[1]); // Captured group
  // ...handle request
}
// Match /api/posts/:year/:slug
const postPattern = /^\/api\/posts\/(\d{4})\/([\w-]+)$/;

const match = '/api/posts/2024/hello-world'.match(postPattern);
if (match) {
  const year = match[1];  // "2024"
  const slug = match[2];  // "hello-world"
}

5. Parsing Query Strings

Query strings are the ?key=value&key2=value2 portion of a URL.

Legacy approach: url.parse()

const url = require('url');

const parsedUrl = url.parse('/products?category=shoes&page=2&sort=price', true);

console.log(parsedUrl.pathname);  // "/products"
console.log(parsedUrl.query);     // { category: 'shoes', page: '2', sort: 'price' }

The second argument true tells url.parse to parse the query string into an object.

Modern approach: new URL()

// new URL() requires a full URL, so we prepend a base
const myUrl = new URL(req.url, `http://${req.headers.host}`);

console.log(myUrl.pathname);                    // "/products"
console.log(myUrl.searchParams.get('category'));  // "shoes"
console.log(myUrl.searchParams.get('page'));      // "2"
console.log(myUrl.searchParams.get('sort'));      // "price"
console.log(myUrl.searchParams.has('category'));  // true
console.log(myUrl.searchParams.has('missing'));   // false

url.parse() vs new URL() comparison

Featureurl.parse() (legacy)new URL() (modern)
StatusDeprecated in newer Node.jsRecommended
Returns query asPlain objectURLSearchParams object
Requires base URLNoYes
Multiple values for same keyOverwritesSupports via getAll()
StandardNode.js specificWHATWG Web Standard

Practical example: paginated API

const http = require('http');

const allProducts = Array.from({ length: 50 }, (_, i) => ({
  id: i + 1,
  name: `Product ${i + 1}`,
  price: Math.floor(Math.random() * 100) + 10
}));

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

  if (url.pathname === '/api/products' && req.method === 'GET') {
    // Parse pagination params with defaults
    const page = parseInt(url.searchParams.get('page')) || 1;
    const limit = parseInt(url.searchParams.get('limit')) || 10;
    const sortBy = url.searchParams.get('sort') || 'id';

    // Validate
    if (page < 1 || limit < 1 || limit > 100) {
      res.writeHead(400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ error: 'Invalid page or limit' }));
      return;
    }

    // Calculate pagination
    const startIndex = (page - 1) * limit;
    const endIndex = startIndex + limit;
    const paginatedProducts = allProducts.slice(startIndex, endIndex);
    const totalPages = Math.ceil(allProducts.length / limit);

    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      success: true,
      data: paginatedProducts,
      pagination: {
        currentPage: page,
        totalPages: totalPages,
        totalItems: allProducts.length,
        itemsPerPage: limit,
        hasNextPage: page < totalPages,
        hasPrevPage: page > 1
      }
    }, null, 2));
  } else {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Not found' }));
  }
});

server.listen(3000, () => {
  console.log('Paginated API on http://localhost:3000');
  console.log('Try: http://localhost:3000/api/products?page=2&limit=5');
});

6. 404 Not Found for Unknown Routes

Always handle the case where no route matches. Without this, the client receives no response and waits forever (or gets a confusing error).

const http = require('http');

const server = http.createServer((req, res) => {
  const { method, url } = req;

  // ─── Known routes ───
  if (url === '/' && method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<h1>Home</h1>');
    return;
  }

  if (url === '/api/health' && method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: 'ok' }));
    return;
  }

  // ─── 404 Catch-all (MUST be at the end) ───
  // Check the Accept header to decide response format
  const acceptsJSON = req.headers['accept'] && req.headers['accept'].includes('application/json');

  if (acceptsJSON) {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      error: 'Not Found',
      message: `Cannot ${method} ${url}`,
      availableRoutes: ['GET /', 'GET /api/health']
    }));
  } else {
    res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end(`
      <html>
      <body style="font-family: Arial; text-align: center; padding: 50px;">
        <h1>404</h1>
        <p>The page <code>${url}</code> was not found.</p>
        <a href="/">Go back home</a>
      </body>
      </html>
    `);
  }
});

server.listen(3000);

7. Route Organization Patterns

As your application grows, you need a better structure than one giant if/else chain.

Pattern 1: Route table object

const http = require('http');

// Define routes as an object
const routes = {
  'GET /': (req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<h1>Home</h1>');
  },

  'GET /about': (req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/html' });
    res.end('<h1>About</h1>');
  },

  'GET /api/users': (req, res) => {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ users: ['Alice', 'Bob'] }));
  },

  'POST /api/users': (req, res) => {
    let body = '';
    req.on('data', chunk => { body += chunk.toString(); });
    req.on('end', () => {
      res.writeHead(201, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ created: JSON.parse(body) }));
    });
  }
};

const server = http.createServer((req, res) => {
  const routeKey = `${req.method} ${req.url}`;
  const handler = routes[routeKey];

  if (handler) {
    handler(req, res);
  } else {
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: 'Route not found' }));
  }
});

server.listen(3000, () => {
  console.log('Route table server on http://localhost:3000');
});

Advantages: clean, easy to add routes, handler functions are isolated. Disadvantages: still no support for dynamic URL parameters or query strings.

Pattern 2: Separate handler files

project/
├── server.js
├── routes/
│   ├── home.js
│   ├── users.js
│   └── products.js
// routes/users.js
function getUsers(req, res) {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({ users: ['Alice', 'Bob'] }));
}

function createUser(req, res) {
  let body = '';
  req.on('data', chunk => { body += chunk.toString(); });
  req.on('end', () => {
    res.writeHead(201, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ created: JSON.parse(body) }));
  });
}

module.exports = { getUsers, createUser };
// server.js
const http = require('http');
const { getUsers, createUser } = require('./routes/users');

const routes = {
  'GET /api/users': getUsers,
  'POST /api/users': createUser
};

const server = http.createServer((req, res) => {
  const handler = routes[`${req.method} ${req.url}`];
  if (handler) {
    handler(req, res);
  } else {
    res.writeHead(404);
    res.end('Not Found');
  }
});

server.listen(3000);

8. Limitations of Manual Routing (Why Express Exists)

After building a few servers with the http module, you will notice these pain points:

ProblemDescription
No path parameters/users/:id does not work — you must parse manually
No middlewareNo easy way to run shared code (logging, auth) before handlers
No body parsingYou must manually collect request body chunks
No built-in static filesYou must write your own file server with MIME type handling
Verbose error handlingEvery handler must manage its own try/catch
Query string hassleMust manually parse query strings on every route
No route groupingCannot group /api/users/* routes together easily
No method handlingMust check req.method in every route

This is exactly why Express.js exists. It provides all of these features with a clean, minimal API. You will learn Express in Section 3.4.

But understanding raw HTTP routing first gives you deep appreciation for what Express does under the hood.


9. Building a Mini Router from Scratch

Let us build a small router class that solves some of the problems above. This shows how frameworks like Express work internally.

const http = require('http');

class Router {
  constructor() {
    this.routes = [];
  }

  // Register a route with method, path pattern, and handler
  addRoute(method, path, handler) {
    // Convert path pattern like "/users/:id" to a regex
    const paramNames = [];
    const regexString = path.replace(/:(\w+)/g, (match, paramName) => {
      paramNames.push(paramName);
      return '([^/]+)';   // Match any non-slash characters
    });

    this.routes.push({
      method: method.toUpperCase(),
      regex: new RegExp(`^${regexString}$`),
      paramNames,
      handler
    });
  }

  // Shorthand methods
  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); }

  // Find and execute the matching route
  handle(req, res) {
    const url = new URL(req.url, `http://${req.headers.host}`);
    const pathname = url.pathname;

    for (const route of this.routes) {
      if (route.method !== req.method) continue;

      const match = pathname.match(route.regex);
      if (match) {
        // Extract parameters
        req.params = {};
        route.paramNames.forEach((name, index) => {
          req.params[name] = match[index + 1];
        });

        // Attach query params
        req.query = Object.fromEntries(url.searchParams);

        // Call the handler
        route.handler(req, res);
        return;
      }
    }

    // No route matched — 404
    res.writeHead(404, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ error: `Cannot ${req.method} ${pathname}` }));
  }
}

// ─── Usage ───

const router = new Router();

// Home page
router.get('/', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'text/html' });
  res.end('<h1>Home Page</h1>');
});

// List all users
router.get('/api/users', (req, res) => {
  const page = req.query.page || '1';
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    users: ['Alice', 'Bob', 'Charlie'],
    page: parseInt(page)
  }));
});

// Get user by ID — :id is a dynamic parameter!
router.get('/api/users/:id', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    message: `Fetching user with ID: ${req.params.id}`
  }));
});

// Get a specific post by user
router.get('/api/users/:userId/posts/:postId', (req, res) => {
  res.writeHead(200, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify({
    userId: req.params.userId,
    postId: req.params.postId
  }));
});

// Create user
router.post('/api/users', (req, res) => {
  let body = '';
  req.on('data', chunk => { body += chunk.toString(); });
  req.on('end', () => {
    res.writeHead(201, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ created: JSON.parse(body) }));
  });
});

// Create the server using our router
const server = http.createServer((req, res) => {
  router.handle(req, res);
});

server.listen(3000, () => {
  console.log('Mini router server on http://localhost:3000');
  console.log('Try: /api/users/42');
  console.log('Try: /api/users?page=2');
  console.log('Try: /api/users/5/posts/10');
});

Testing the mini router

# Home page
curl http://localhost:3000/

# List users with pagination
curl http://localhost:3000/api/users?page=3

# Get specific user (dynamic :id parameter)
curl http://localhost:3000/api/users/42

# Nested params
curl http://localhost:3000/api/users/5/posts/10

# Create user
curl -X POST http://localhost:3000/api/users \
  -H "Content-Type: application/json" \
  -d '{"name":"Diana"}'

# 404
curl http://localhost:3000/nonexistent

What this mini router teaches you

  1. Route definitions convert path patterns to regular expressions.
  2. Dynamic parameters (:id) are captured using regex groups.
  3. Query strings are parsed separately from the path.
  4. Routes are checked in order — first match wins.
  5. This is essentially what Express does behind the scenes (with far more features).

Key Takeaways

  1. Routing maps incoming URLs and methods to handler functions.
  2. Basic routing with if/else on req.url works but does not scale well.
  3. Method-based routing lets you handle GET, POST, PUT, DELETE on the same URL path.
  4. URL parameters (/users/42) must be extracted manually using split() or regex.
  5. Query strings are parsed using new URL() (recommended) or the legacy url.parse().
  6. Always include a 404 catch-all route at the end.
  7. Route tables and separate handler files improve code organization.
  8. Manual routing has many limitations — Express solves them elegantly.
  9. Building a mini router teaches you what frameworks do under the hood.

Explain-It Challenge

Build a complete CRUD API using only the http module:

Create a server that manages a list of "books" (stored in an array). Implement:

MethodURLAction
GET/api/booksList all books (support ?genre=fiction filter)
GET/api/books/:idGet a single book
POST/api/booksAdd a new book (from JSON body)
PUT/api/books/:idUpdate a book
DELETE/api/books/:idDelete a book

Each book has: id, title, author, genre, year.

Then explain to a study partner: what happens when a route matches /api/books before /api/books/:id? Why does order matter? And why would all of this be simpler with Express?


Navigation: ← 3.2 Overview | Next → 3.2.e HTTP Status Codes