Express.js is the most widely-used Node.js web framework. It is deliberately minimal — it adds routing, middleware, and response helpers on top of Node’s built-in http module without imposing a rigid project structure. Understanding what Express actually does under the hood, how the app object works, and how the req and res objects expose every piece of information about an incoming request and every tool for building a response is the foundation for everything else in this chapter. You will leave this lesson knowing how to create a working Express server from scratch and understanding every line you write.
Express.js Core Objects
| Object | What It Is | Key Properties / Methods |
|---|---|---|
app |
The Express application instance | app.use(), app.get/post/put/delete(), app.listen(), app.set() |
req |
Incoming HTTP request object (extends Node’s IncomingMessage) | req.params, req.query, req.body, req.headers, req.method, req.path, req.ip |
res |
Outgoing HTTP response object (extends Node’s ServerResponse) | res.json(), res.send(), res.status(), res.redirect(), res.cookie(), res.set() |
next |
Function to pass control to the next middleware or route handler | next() — continue chain, next(err) — jump to error handler |
Router |
Mini-application — groups related routes with a common prefix | express.Router(), same HTTP methods as app |
The req Object — Reading the Incoming Request
| Property | Contains | Example URL / Value |
|---|---|---|
req.params |
URL route parameters (from :param) |
/users/42 → { id: '42' } |
req.query |
URL query string parsed as object | /tasks?page=2&limit=10 → { page: '2', limit: '10' } |
req.body |
Parsed request body (requires express.json()) |
{ title: 'Learn Express', priority: 'high' } |
req.headers |
All HTTP request headers (lowercase keys) | { authorization: 'Bearer ...', 'content-type': 'application/json' } |
req.method |
HTTP method as uppercase string | 'GET', 'POST', 'DELETE' |
req.path |
URL path without query string | '/api/tasks' |
req.url |
Full URL path including query string | '/api/tasks?page=2' |
req.ip |
Remote IP address of the client | '127.0.0.1' |
req.get(header) |
Get a specific request header value | req.get('Authorization') |
req.cookies |
Parsed cookies (requires cookie-parser) | { sessionId: 'abc123' } |
The res Object — Building the Response
| Method | Sends | Example |
|---|---|---|
res.json(obj) |
JSON body, sets Content-Type: application/json | res.json({ success: true, data: task }) |
res.send(data) |
String, Buffer, or object — Content-Type inferred | res.send('Hello World') |
res.status(code) |
Sets HTTP status code — chainable | res.status(201).json(task) |
res.sendStatus(code) |
Status code + default status text as body | res.sendStatus(204) |
res.redirect(url) |
302 redirect (or specify code) | res.redirect(301, '/new-path') |
res.set(header, value) |
Sets a response header | res.set('X-Total-Count', '42') |
res.cookie(name, val, opts) |
Sets a Set-Cookie header | res.cookie('token', jwt, { httpOnly: true }) |
res.clearCookie(name) |
Clears a cookie | res.clearCookie('token') |
res.sendFile(path) |
Sends a file as the response | res.sendFile(path.join(__dirname, 'index.html')) |
res.download(path) |
Prompts a file download | res.download('/reports/report.pdf') |
http.IncomingMessage and http.ServerResponse. The req and res objects you receive in every handler are those same Node objects, extended with Express convenience methods. This means everything you know about Node’s HTTP module still applies — Express just makes common operations like parsing JSON bodies, setting headers, and sending responses dramatically more concise.res.status() with a response method — res.status(201).json(data). Calling res.status(201) alone does not send the response — it just sets the status code on the object and returns res for chaining. The response is only sent when you call a terminal method: res.json(), res.send(), res.end(), res.sendFile(), or res.redirect().res.json() after res.send() — or calling either twice — throws a Cannot set headers after they are sent error. This is one of the most common Express bugs. If you return early after sending a response, always use return res.json() so the function exits immediately and cannot accidentally reach a second response call.Basic Example — Building Express from Scratch
// ── Minimal Express server (what Express looks like under the hood) ────────
const http = require('http');
// Without Express — raw Node.js
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
} else {
res.writeHead(404);
res.end('Not Found');
}
});
server.listen(3000);
// ── The same server WITH Express — cleaner, more powerful ─────────────────
const express = require('express');
const app = express();
app.use(express.json()); // parse JSON request bodies
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(3000, () => console.log('Server on port 3000'));
// ── Understanding the app object ──────────────────────────────────────────
const express = require('express');
const app = express();
// app.set / app.get — configure the application
app.set('trust proxy', 1); // trust first proxy (for correct req.ip behind nginx)
app.set('x-powered-by', false); // hide Express version header
console.log(app.get('env')); // 'development' or 'production' (from NODE_ENV)
// Register middleware — runs for every request
app.use(express.json({ limit: '10mb' })); // parse JSON bodies
app.use(express.urlencoded({ extended: true })); // parse form bodies
// ── The req object — everything about the incoming request ─────────────────
app.get('/api/users/:id', (req, res) => {
console.log(req.method); // 'GET'
console.log(req.path); // '/api/users/42'
console.log(req.params); // { id: '42' }
console.log(req.query); // { include: 'posts' } (from ?include=posts)
console.log(req.headers); // { host: 'localhost:3000', authorization: '...' }
console.log(req.get('Host')); // 'localhost:3000'
console.log(req.ip); // '127.0.0.1'
console.log(req.secure); // true if HTTPS
console.log(req.hostname); // 'localhost'
res.json({ userId: req.params.id });
});
// ── The res object — sending the response ────────────────────────────────
app.post('/api/users', (req, res) => {
const { name, email } = req.body;
// Set custom headers
res.set('X-Request-Id', 'uuid-here');
res.set('Cache-Control', 'no-store');
// Chain status + json — most common pattern
return res.status(201).json({
success: true,
data: { id: 1, name, email },
});
});
// ── Sending different response types ─────────────────────────────────────
app.get('/text', (req, res) => res.send('Plain text'));
app.get('/html', (req, res) => res.send('HTML
'));
app.get('/json', (req, res) => res.json({ key: 'value' }));
app.get('/redirect', (req, res) => res.redirect('/new-location'));
app.get('/gone', (req, res) => res.sendStatus(410)); // 410 Gone
app.get('/no-body', (req, res) => res.status(204).end()); // 204 No Content
Separating the App from the Server
// backend/src/app.js — Express application setup (no listen here)
const express = require('express');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
// Export the app — do NOT call app.listen() here
module.exports = app;
// backend/src/index.js — entry point — starts the server
const app = require('./app');
const connectDB = require('./config/database');
const PORT = process.env.PORT || 3000;
async function startServer() {
await connectDB();
const server = app.listen(PORT, () => {
console.log(`API running on http://localhost:${PORT}`);
});
return server;
}
startServer().catch(err => {
console.error('Failed to start:', err);
process.exit(1);
});
// Why separate app from server?
// 1. Tests can import app without starting a real server
// 2. Graceful shutdown logic stays in index.js, not app.js
// 3. app.js stays focused on request handling
How It Works
Step 1 — express() Creates an Application Function
Calling express() returns a JavaScript function that is both the application configuration object and a valid Node.js request handler. When you call app.listen(port), Express creates a Node.js http.Server and passes app as the request listener. Every HTTP request that arrives is handled by Express’s internal dispatcher, which walks through the registered middleware and routes to find a match.
Step 2 — express.json() Parses the Request Body
By default, req.body is undefined. When a POST or PUT request arrives with a JSON body, the raw bytes need to be read from the request stream and parsed. express.json() is a middleware that does this automatically for every request with Content-Type: application/json. Without it, req.body is always undefined and you cannot access POST data.
Step 3 — HTTP Method Registration: app.get, app.post, app.put, app.delete
Each method registers a route handler for a specific HTTP method + URL path combination. Express stores these as an ordered list. When a request arrives, Express iterates through the list and calls the first handler whose method and path match. If multiple handlers match the same path (useful for middleware), they form a chain connected by next().
Step 4 — Response Methods End the Response
res.json() serialises the object to JSON, sets Content-Type: application/json, and calls res.end() to send the response to the client. After a response method is called, the HTTP connection is either closed or kept alive for the next request (keep-alive). Attempting to call another response method throws an error because headers have already been sent.
Step 5 — Separating app.js from index.js Improves Testability
When your tests require('./app'), they get the Express application without starting a real TCP server on a port. Testing libraries like Supertest create an in-process HTTP server for each test request, making tests fast and port-conflict-free. The index.js entry point is only executed when running the application — not during testing.
Real-World Example: Structured Express Application Bootstrap
// backend/src/app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const authRoutes = require('./routes/auth.routes');
const taskRoutes = require('./routes/task.routes');
const errorHandler = require('./middleware/errorHandler');
const { notFound } = require('./middleware/notFound');
function createApp() {
const app = express();
// ── Security middleware ──────────────────────────────────────────────
app.use(helmet());
app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:4200',
credentials: true,
}));
// ── Body parsing ─────────────────────────────────────────────────────
app.use(express.json({ limit: '5mb' }));
app.use(express.urlencoded({ extended: true, limit: '5mb' }));
// ── Request logging ───────────────────────────────────────────────────
if (process.env.NODE_ENV !== 'test') {
app.use(morgan('dev'));
}
// ── Health check — unauthenticated ───────────────────────────────────
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
});
});
// ── API routes ────────────────────────────────────────────────────────
app.use('/api/auth', authRoutes);
app.use('/api/tasks', taskRoutes);
// ── 404 handler — must come after all valid routes ────────────────────
app.use(notFound);
// ── Global error handler — must be last, 4 parameters ────────────────
app.use(errorHandler);
return app;
}
module.exports = createApp;
// backend/src/index.js
const createApp = require('./app');
const connectDB = require('./config/database');
require('dotenv').config();
const PORT = parseInt(process.env.PORT, 10) || 3000;
async function main() {
const app = createApp();
await connectDB();
app.listen(PORT, () => console.log(`Server on http://localhost:${PORT}`));
}
main().catch(err => { console.error(err); process.exit(1); });
Common Mistakes
Mistake 1 — Calling res.json() twice in the same handler
❌ Wrong — missing return causes a second response call:
app.get('/user/:id', async (req, res) => {
const user = await findUser(req.params.id);
if (!user) {
res.status(404).json({ message: 'Not found' });
// MISSING return — falls through to the line below!
}
res.json(user); // Error: Cannot set headers after they are sent
});
✅ Correct — always return after sending a response:
app.get('/user/:id', async (req, res) => {
const user = await findUser(req.params.id);
if (!user) return res.status(404).json({ message: 'Not found' });
return res.json(user);
});
Mistake 2 — Forgetting express.json() middleware
❌ Wrong — req.body is undefined without the body parser:
const app = express();
// Missing: app.use(express.json());
app.post('/api/users', (req, res) => {
console.log(req.body); // undefined!
const { name } = req.body; // TypeError: Cannot destructure property 'name' of undefined
});
✅ Correct — register body parsers before your routes:
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.post('/api/users', (req, res) => {
const { name } = req.body; // works correctly
});
Mistake 3 — Calling app.listen() inside app.js
❌ Wrong — app.js starts a real server on import, breaking tests:
// app.js — DO NOT do this
const app = express();
app.listen(3000); // starts server immediately on require('./app')
module.exports = app; // tests that import this will bind to port 3000
✅ Correct — only configure in app.js, start in index.js:
// app.js — configure only
const app = express();
module.exports = app; // no listen() here
// index.js — start here
require('./app').listen(3000);
Quick Reference
| Task | Code |
|---|---|
| Create app | const app = express() |
| Parse JSON body | app.use(express.json()) |
| Parse form body | app.use(express.urlencoded({ extended: true })) |
| GET route | app.get('/path', (req, res) => res.json(data)) |
| POST route | app.post('/path', (req, res) => res.status(201).json(data)) |
| URL param | req.params.id |
| Query string | req.query.page |
| Request body | req.body.name |
| Set header | res.set('X-Custom', 'value') |
| Start server | app.listen(3000, () => console.log('Ready')) |