Creating Routes and Handling Requests with Express.js: A Practical Guide

Creating Routes and Handling Requests with Express.js
Audience: This post assumes you know basic JavaScript and have Node.js installed. No prior Express experience required.
TL;DR: Express.js wraps Node's http module with a routing layer and middleware system. You define routes with app.get(), app.post(), etc., attach handler functions, and send responses with res.json() or res.send(). The result is readable, maintainable server code without manual URL parsing.
Problem
Node.js ships with a built-in http module. It works, but it makes you do everything manually — parse URLs, check HTTP methods, route requests, set headers, and serialize response bodies. Here's what a minimal server looks like in raw Node:
// raw-node-server.js
const http = require('http');
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/users') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ users: [] }));
} else if (req.method === 'POST' && req.url === '/users') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
const data = JSON.parse(body);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ created: data }));
});
} else {
res.writeHead(404);
res.end('Not found');
}
});
server.listen(3000, () => console.log('Server running on port 3000'));
This scales poorly. Adding five more routes turns into a deeply nested if-else tree. You're also manually streaming request bodies, setting headers on every response, and doing string matching on URLs.
Express solves all of this.
What Express.js Is
Express is a minimal web framework for Node.js. It wraps the native http module and adds:
- A routing layer — match requests by method + path without if-else chains
- Middleware pipeline — run functions before your route handler (auth, body parsing, logging)
- Response helpers —
res.json(),res.status(),res.send()instead of manualwriteHead+end - Request parsing — access
req.params,req.query,req.bodydirectly
It does not make architectural decisions for you. No ORM, no templating engine is forced on you. It's a routing + middleware layer, nothing more.
Solution
Step 1: Install Express
mkdir express-routing-demo && cd express-routing-demo
npm init -y
npm install express
This creates a project with Express as the only dependency. Node version 16+ is recommended.
Step 2: Create Your First Express Server
// server.js
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json()); // Parse JSON request bodies automatically
app.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});
express() returns an application object. app.use(express.json()) registers a built-in middleware that parses incoming JSON bodies and attaches the result to req.body. Without this, req.body is undefined for POST requests with JSON payloads.
Expected output when running node server.js:
Server listening on http://localhost:3000
Step 3: Handle GET Requests
Routes follow this pattern:
app.METHOD(PATH, HANDLER)
METHOD— HTTP verb in lowercase (get,post,put,delete)PATH— URL string or patternHANDLER— function that receives(req, res)
// In-memory data store for this demo
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
// GET /users — return all users
app.get('/users', (req, res) => {
res.status(200).json({ users });
});
// GET /users/:id — return a single user by ID
app.get('/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const user = users.find(u => u.id === userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json({ user });
});
Key concepts here:
req.params.id— Express extracts the:idsegment from the URL automatically. No manual URL parsing.res.status(200).json({ user })— chains the status code with the JSON response helper. Express sets theContent-Type: application/jsonheader automatically.- Returning early with
return res.status(404).json(...)prevents the handler from continuing after sending the 404 response.
Test it:
curl http://localhost:3000/users
# {"users":[{"id":1,"name":"Alice","email":"alice@example.com"},{"id":2,"name":"Bob","email":"bob@example.com"}]}
curl http://localhost:3000/users/1
# {"user":{"id":1,"name":"Alice","email":"alice@example.com"}}
curl http://localhost:3000/users/99
# {"error":"User not found"}
Step 4: Handle POST Requests
// POST /users — create a new user
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const newUser = {
id: users.length + 1,
name,
email,
};
users.push(newUser);
res.status(201).json({ user: newUser });
});
req.body contains the parsed JSON payload because we registered express.json() middleware in Step 2. Without it, req.body would be undefined — a common mistake when starting with Express.
Test it:
curl -X POST http://localhost:3000/users \
-H 'Content-Type: application/json' \
-d '{"name": "Carol", "email": "carol@example.com"}'
# {"user":{"id":3,"name":"Carol","email":"carol@example.com"}}
curl -X POST http://localhost:3000/users \
-H 'Content-Type: application/json' \
-d '{"name": "Dave"}'
# {"error":"name and email are required"}
Step 5: Complete Working Server
Here is the full file combining everything above:
// server.js
const express = require('express');
const app = express();
const PORT = 3000;
app.use(express.json());
const users = [
{ id: 1, name: 'Alice', email: 'alice@example.com' },
{ id: 2, name: 'Bob', email: 'bob@example.com' },
];
// GET /users
app.get('/users', (req, res) => {
res.status(200).json({ users });
});
// GET /users/:id
app.get('/users/:id', (req, res) => {
const userId = parseInt(req.params.id, 10);
const user = users.find(u => u.id === userId);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.status(200).json({ user });
});
// POST /users
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || !email) {
return res.status(400).json({ error: 'name and email are required' });
}
const newUser = { id: users.length + 1, name, email };
users.push(newUser);
res.status(201).json({ user: newUser });
});
// 404 fallback — must be registered last
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
app.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});
The 404 fallback is registered with app.use() at the end. Express processes routes in registration order — if no defined route matches, the request falls through to this handler.
Request → Route Handler → Response Flow
Incoming HTTP Request
│
▼
┌──────────────────────┐
│ express.json() │ ← Middleware: parses body, attaches to req.body
└──────────────────────┘
│
▼
┌──────────────────────┐
│ Route Matching │ ← app.get('/users') / app.post('/users/:id')
│ (method + path) │
└──────────────────────┘
│
▼
┌──────────────────────┐
│ Route Handler │ ← Your function: reads req, builds response
│ (req, res) => {} │
└──────────────────────┘
│
▼
┌──────────────────────┐
│ res.json() / │ ← Sends HTTP response with headers + body
│ res.send() │
└──────────────────────┘
│
▼
HTTP Response
Results
Compare the same two-route implementation:
| Metric | Raw Node http | Express |
| Lines for 2 routes | ~28 lines | ~18 lines |
| Manual body streaming | Required | Handled by middleware |
| URL param extraction | Manual string split | req.params.id |
| JSON serialization | JSON.stringify + headers | res.json() |
| 404 handling | Manual else block | app.use() fallback |
As the number of routes grows, the gap widens significantly. A 10-route raw Node server typically requires 80-120 lines of routing logic alone. The same Express server stays under 60 lines.
Trade-offs
What Express adds on top of raw Node:
- A small runtime overhead per request (~0.1–0.3ms on modern hardware for middleware processing). Negligible for most applications.
- An additional dependency. If Express has a vulnerability, your app inherits it.
- Middleware execution order matters — registering
express.json()after your routes means request bodies won't be parsed in those routes. Bugs from ordering are subtle.
What Express does NOT give you:
- Input validation — use
zodorexpress-validator - Authentication — handle separately (Passport, JWT middleware)
- Database abstraction — no ORM included
- Request timeout handling — must be added explicitly
For very high-throughput internal services (100k+ req/s), frameworks like Fastify have a measurable performance advantage over Express due to a more efficient routing implementation and serialization layer. For most production APIs handling under 10k req/s, Express performance is not the bottleneck.
Conclusion
Express reduces Node.js server code to its essential parts: define a route, read the request, send a response. The routing layer (app.get, app.post), automatic parameter extraction (req.params, req.body), and response helpers (res.json, res.status) eliminate the boilerplate that makes raw Node HTTP servers hard to maintain.
The next step is organizing routes using express.Router() — which lets you split routes into separate files and mount them on path prefixes, keeping your codebase modular as it grows.
Further Reading
- Express.js Official Routing Guide — Covers route parameters, query strings, and
express.Router - Express Middleware Reference — Explains the middleware pipeline in detail
- Node.js HTTP Module Docs — Useful for understanding what Express abstracts
- Fastify vs Express Benchmark — Official Fastify benchmarks comparing throughput
- express-validator — Input validation middleware for Express routes



