Difficulty: Beginner
When building a web server without a framework, you need to manually handle different routes by inspecting the request URL and HTTP method. This is the fundamental concept behind all web routing - every framework like Express simply provides a cleaner API on top of this same mechanism.
The req.url property contains the path and query string of the incoming request (e.g., '/users?page=2'). To build a router, you use conditional statements (if/else or switch) to check req.url and req.method, then execute different logic for each combination. For example, GET /users might return a list of users, while POST /users creates a new user. This is the essence of RESTful routing.
Query strings are the key-value pairs that appear after the '?' in a URL. For example, in '/search?q=node&page=2', the query string contains q=node and page=2. In modern Node.js, you can parse these using the URL constructor and its searchParams property, which provides a clean API for accessing individual parameters. The older url.parse() method from the 'url' module still works but is considered legacy.
As your application grows, manual routing with if/else chains becomes unwieldy. This is exactly why frameworks like Express exist - they provide a declarative routing API that is easier to read, maintain, and extend. However, understanding manual routing is valuable because it demystifies what frameworks do under the hood and helps you debug routing issues more effectively.
A common pattern in manual routing is to separate the URL path from the query string before matching routes. You can do this with the URL constructor: new URL(req.url, 'http://localhost') gives you a URL object with separate pathname and searchParams properties. This avoids the problem of '/users' not matching '/users?page=2' in a simple string comparison.
const http = require('http');
const server = http.createServer((req, res) => {
const { method, url } = req;
if (method === 'GET' && url === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>Home Page</h1>');
} else if (method === 'GET' && url === '/about') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<h1>About Page</h1>');
} else if (method === 'GET' && url === '/api/users') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]));
} else {
res.writeHead(404, { 'Content-Type': 'text/html' });
res.end('<h1>404 - Page Not Found</h1>');
}
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000/');
});
This demonstrates the basic pattern: check req.method and req.url to determine which route was requested, then respond accordingly. Every unmatched route falls through to the 404 handler.
const http = require('http');
const server = http.createServer((req, res) => {
// Parse the URL to separate path and query string
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = parsedUrl.pathname;
const query = parsedUrl.searchParams;
if (req.method === 'GET' && pathname === '/search') {
const searchTerm = query.get('q') || '';
const page = parseInt(query.get('page')) || 1;
const limit = parseInt(query.get('limit')) || 10;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
search: searchTerm,
page: page,
limit: limit,
message: `Searching for "${searchTerm}" - page ${page}`
}));
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
});
server.listen(3000, () => {
console.log('Server running at http://localhost:3000/');
console.log('Try: http://localhost:3000/search?q=node&page=2&limit=5');
});
The URL constructor parses the full URL and gives you searchParams, which has methods like get(), getAll(), has(), and entries(). Always provide default values when reading query parameters since they may be missing.
const http = require('http');
// Simple in-memory data store
let todos = [
{ id: 1, title: 'Learn Node.js', done: false },
{ id: 2, title: 'Build a server', done: false }
];
const server = http.createServer((req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const pathname = parsedUrl.pathname;
// Set JSON content type for all API responses
res.setHeader('Content-Type', 'application/json');
if (pathname === '/api/todos') {
switch (req.method) {
case 'GET':
res.writeHead(200);
res.end(JSON.stringify(todos));
break;
case 'POST':
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
const newTodo = JSON.parse(body);
newTodo.id = todos.length + 1;
newTodo.done = false;
todos.push(newTodo);
res.writeHead(201);
res.end(JSON.stringify(newTodo));
});
break;
default:
res.writeHead(405);
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
}
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not Found' }));
}
});
server.listen(3000, () => {
console.log('Todo API running at http://localhost:3000/');
});
A switch statement on req.method provides cleaner routing than nested if/else. This pattern handles GET to list all todos and POST to create a new one. Status 405 (Method Not Allowed) is returned for unsupported methods on a valid route.
URL Parsing, Method-based Routing, Query Strings, url.parse, URLSearchParams, Route Handling