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:
- The URL path — where the client wants to go (
/,/about,/api/users). - 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/elsechain 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.urlfor/about?ref=googleis"/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
| Feature | url.parse() (legacy) | new URL() (modern) |
|---|---|---|
| Status | Deprecated in newer Node.js | Recommended |
| Returns query as | Plain object | URLSearchParams object |
| Requires base URL | No | Yes |
| Multiple values for same key | Overwrites | Supports via getAll() |
| Standard | Node.js specific | WHATWG 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:
| Problem | Description |
|---|---|
| No path parameters | /users/:id does not work — you must parse manually |
| No middleware | No easy way to run shared code (logging, auth) before handlers |
| No body parsing | You must manually collect request body chunks |
| No built-in static files | You must write your own file server with MIME type handling |
| Verbose error handling | Every handler must manage its own try/catch |
| Query string hassle | Must manually parse query strings on every route |
| No route grouping | Cannot group /api/users/* routes together easily |
| No method handling | Must 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
- Route definitions convert path patterns to regular expressions.
- Dynamic parameters (
:id) are captured using regex groups. - Query strings are parsed separately from the path.
- Routes are checked in order — first match wins.
- This is essentially what Express does behind the scenes (with far more features).
Key Takeaways
- Routing maps incoming URLs and methods to handler functions.
- Basic routing with
if/elseonreq.urlworks but does not scale well. - Method-based routing lets you handle GET, POST, PUT, DELETE on the same URL path.
- URL parameters (
/users/42) must be extracted manually usingsplit()or regex. - Query strings are parsed using
new URL()(recommended) or the legacyurl.parse(). - Always include a 404 catch-all route at the end.
- Route tables and separate handler files improve code organization.
- Manual routing has many limitations — Express solves them elegantly.
- 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:
| Method | URL | Action |
|---|---|---|
| GET | /api/books | List all books (support ?genre=fiction filter) |
| GET | /api/books/:id | Get a single book |
| POST | /api/books | Add a new book (from JSON body) |
| PUT | /api/books/:id | Update a book |
| DELETE | /api/books/:id | Delete 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