Request & Response

Express.js Fundamentals — Request & Response

Express.js Fundamentals

Chapter 4 of 8  ·  Request & Response

Request & Response

Every Express route handler receives two enhanced objects: req (the incoming request) and res (the outgoing response). Express wraps Node's raw IncomingMessage and ServerResponse with a richer API, adding convenience methods for reading headers, parsing bodies, setting cookies, and sending structured responses. Knowing the full surface area of both objects means you stop re-implementing things Express already gives you for free.

The req Object

Identity Properties

These properties tell you what the client asked for.

URL Properties

req.method'GET', 'POST', …

req.path — path only, no query string ('/users/42')

req.url — path + query string ('/users/42?sort=asc'). Relative to the mount point of the current router.

req.originalUrl — full URL as received, even inside a nested router

req.hostname — host header without port ('api.example.com')

req.ip — client IP. Set app.set('trust proxy', 1) if behind a reverse proxy to read the real IP from X-Forwarded-For.

req.protocol'http' or 'https'

req.secure — shorthand for req.protocol === 'https'

Parsed Data

req.params — named URL segments (:id)

req.query — parsed query string object

req.body — parsed request body (needs express.json() or similar)

req.cookies — parsed cookies (needs cookie-parser)

req.signedCookies — cookies signed with a secret (needs cookie-parser with a secret)

req.headers — all request headers as a lowercase-keyed object

req.get() and req.is()

Two convenience methods for working with headers and content types:

// req.get(name) — case-insensitive header lookup const auth = req.get('Authorization'); // same as req.headers['authorization'] const ua = req.get('User-Agent'); const ct = req.get('Content-Type'); // req.is(type) — checks Content-Type, returns the matched type or false if (req.is('application/json')) { /* handle JSON */ } if (req.is('multipart/*')) { /* handle file upload */ } if (req.is(['json', 'urlencoded'])) { /* handle either */ } // Pass shorthand types ('json', 'html', 'text') or full MIME strings // req.accepts(types) — content negotiation via the Accept header // Returns the best match or false app.get('/data', (req, res) => { const best = req.accepts(['json', 'html']); if (best === 'json') return res.json({ key: 'value' }); if (best === 'html') return res.send('<p>value</p>'); res.status(406).send('Not Acceptable'); });

Body Parsing in Depth

Without a body-parsing middleware, req.body is undefined. Express ships four built-in parsers; you pick the one that matches what clients send.

MiddlewareParsesreq.body typeWhen to use
express.json() application/json Object / Array REST APIs — the standard choice for JSON clients
express.urlencoded() application/x-www-form-urlencoded Object HTML form submissions (<form method="POST">)
express.raw() application/octet-stream (or custom) Buffer Webhook signature verification (Stripe, GitHub) — need raw bytes
express.text() text/plain String Plain text payloads, some webhook providers
// Most apps use both json and urlencoded app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: false, limit: '1mb' })); // Webhook signature verification with raw body // Must apply express.raw() BEFORE express.json() — they check Content-Type // and each parser only runs if the content type matches. app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), // raw Buffer for THIS route (req, res) => { const sig = req.get('stripe-signature'); const secret = process.env.STRIPE_WEBHOOK_SECRET; try { // stripe.webhooks.constructEvent() needs the raw Buffer, not parsed JSON const event = stripe.webhooks.constructEvent(req.body, sig, secret); res.json({ received: true }); } catch (err) { res.status(400).send(`Webhook error: ${err.message}`); } } );

The res Object

Sending Responses

MethodWhat it sendsContent-Type
res.json(data)JSON-serialised object/array. Calls JSON.stringify.application/json
res.send(body)String → HTML. Buffer → binary. Object → JSON (calls res.json internally).Auto-detected
res.sendStatus(code)Status code + its name as plain text (404 Not Found).text/plain
res.sendFile(path)Streams a file. Path must be absolute. Sets Content-Type from extension.Auto from ext
res.download(path, name?)Like sendFile but adds Content-Disposition: attachment — triggers browser download.Auto from ext
res.redirect(url)302 redirect by default. Pass a status code first: res.redirect(301, url).
res.end()Ends the response with no body. Use res.sendStatus(204) instead for clarity.
// res.status() is chainable — set status then send res.status(201).json({ id: newUser.id, name: newUser.name }); res.status(404).json({ error: 'Not found' }); res.status(204).end(); // 204 No Content — no body res.sendStatus(204); // shorthand for same thing // Sending a file const path = require('node:path'); app.get('/invoice/:id', (req, res) => { const file = path.join(__dirname, 'invoices', `invoice-${req.params.id}.pdf`); res.sendFile(file, (err) => { // callback is optional; fires on error if (err) res.status(404).json({ error: 'Invoice not found' }); }); }); // Triggering a browser download app.get('/export/csv', (req, res) => { res.download(path.join(__dirname, 'exports/data.csv'), 'my-data.csv'); // ↑ filename the browser sees });

Response Headers

// res.set(field, value) — set a single header (chainable) res.set('X-Request-Id', req.id) .set('Cache-Control', 'no-store') .json(data); // res.set(object) — set multiple headers at once res.set({ 'X-Powered-By': 'My API', 'Cache-Control': 'public, max-age=300', 'ETag': '"abc123"', }); // res.get(field) — read a header you already set (useful for logging) const ct = res.get('Content-Type'); // res.type(type) — shorthand for setting Content-Type res.type('json'); // application/json res.type('html'); // text/html res.type('png'); // image/png res.type('application/pdf'); // res.append(field, value) — append to an existing header (e.g. Set-Cookie) res.append('Link', '</api/users>; rel="collection"');

res.locals

res.locals is a plain object scoped to a single request. Middleware uses it to pass data to later middleware or route handlers without polluting req.

// Middleware sets a value app.use((req, res, next) => { res.locals.startTime = Date.now(); res.locals.requestId = crypto.randomUUID(); next(); }); // Later handler reads it app.get('/profile', (req, res) => { res.json({ data: { name: 'Alice' }, requestId: res.locals.requestId, tookMs: Date.now() - res.locals.startTime, }); });

Cookies

Cookies are small pieces of data the server sends to the browser, which the browser then includes in all subsequent requests to that origin. Express reads cookies via req.cookies (requires cookie-parser middleware) and sets them via res.cookie().

// npm install cookie-parser const cookieParser = require('cookie-parser'); // Mount before any route that needs to read cookies // Pass a secret to enable signed cookies app.use(cookieParser(process.env.COOKIE_SECRET));
// Setting a cookie res.cookie('sessionId', token, { httpOnly: true, // JS cannot read this cookie — XSS protection secure: true, // only sent over HTTPS sameSite: 'strict', // 'strict' | 'lax' | 'none' — CSRF protection maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms path: '/', // cookie sent for all paths domain: '.example.com', // accessible to all subdomains }); res.json({ ok: true }); // Reading cookies app.get('/me', (req, res) => { const sessionId = req.cookies.sessionId; if (!sessionId) return res.status(401).json({ error: 'Not logged in' }); res.json({ sessionId }); }); // Signed cookies — value is HMAC-signed; tampering invalidates it res.cookie('userId', '42', { signed: true, httpOnly: true, maxAge: 86400000 }); // req.signedCookies.userId → '42' if valid, false if tampered // Clearing a cookie — must match the original path/domain options res.clearCookie('sessionId', { path: '/' }); res.json({ loggedOut: true });

Cookie Security Options

httpOnly: true — prevents JavaScript (including XSS payloads) from reading the cookie. Always set for session tokens.

secure: true — only sent over HTTPS. Set in production. In development, use process.env.NODE_ENV === 'production' to conditionally apply.

sameSite: 'strict' — cookie not sent on cross-site requests at all. 'lax' allows top-level navigations (e.g. clicking a link). Use 'strict' for auth cookies.

Cookie vs Authorization Header

Cookies — browser sends automatically; no JS required. Work well for browser-based apps. Require sameSite/httpOnly to be secure. CSRF protection required.

Authorization: Bearer <token> — must be set explicitly (JS reads token from storage). Suits SPAs and mobile clients. No CSRF risk since it must be set intentionally. No httpOnly equivalent.

Chapter 7 covers JWT auth in detail, including which storage approach to use.

⚠ Never Store Sensitive Data in Cookie Values

Cookie values are sent in plain text (only the transport is encrypted by HTTPS — the browser can still read them). Store an opaque session ID or a signed token, not the actual user data, permissions, or secrets. Signed cookies protect against tampering but not against reading — use httpOnly to prevent JS access, and HTTPS to prevent network interception.

💡 req.path vs req.url vs req.originalUrl

req.path — path only, no query string. Relative to the current router's mount point.

req.url — path + query string, relative to the router mount point. Express rewrites this internally as the request passes through nested routers.

req.originalUrl — the full URL as the client sent it, unchanged through all router levels. Use this in logging middleware — it's what the client actually requested.

Example: app mounts /api router, request is GET /api/users?sort=asc. Inside the router handler: req.path = '/users', req.url = '/users?sort=asc', req.originalUrl = '/api/users?sort=asc'.

Coding Challenges

Challenge 1 — req Deep Dive

Build an app with three routes that exercise the full req API. GET /inspect — returns a JSON snapshot of req.method, req.path, req.originalUrl, req.hostname, req.ip, req.protocol, all request headers, and all query params. POST /content-check — uses req.is() to detect whether the body is JSON or form-encoded; if JSON, echoes req.body; if urlencoded, echoes req.body; if neither, returns 415. GET /negotiate — uses req.accepts(['json', 'html', 'text']) to respond with the appropriate format: JSON object, an HTML paragraph, or plain text; returns 406 if none match. Write supertest tests covering each branch of each route — include tests that send an Accept header requesting HTML and verify the response Content-Type.

View sample solution ↗

Challenge 2 — res Deep Dive

Build an app demonstrating the full res API. GET /headers-demo — sets three custom response headers (X-App-Version, Cache-Control: no-store, X-Request-Id from a crypto.randomUUID()), then responds with JSON. GET /redirect-demo — accepts a ?to= query param; if missing, 400; if the value is not in an allowlist ['/safe-a', '/safe-b'], 403; otherwise, 302 redirect. GET /safe-a and GET /safe-b each return a simple JSON response. GET /locals-demo — mount a middleware that writes res.locals.startTime and res.locals.requestId; the route handler reads both from res.locals and returns them in the response. Write supertest tests verifying all headers, the redirect chain, the 403 on bad destination, and that res.locals values appear in the response.

View sample solution ↗

Challenge 3 — Cookie Jar

Build a minimal cookie-based "session" using cookie-parser with a signed cookie. Four routes: POST /login — expects { username, password } in the body; validates against a hardcoded map ({ alice: 'pass1', bob: 'pass2' }); on success, sets a signed session cookie containing the username (httpOnly: true, maxAge: 600_000) and returns { ok: true }; on failure, 401. GET /profile — reads req.signedCookies.session; if absent or false (tampered), 401; otherwise returns { username }. POST /logout — clears the session cookie and returns { loggedOut: true }. Write supertest tests using a cookie jar (supertest's agent() maintains cookies between requests) to simulate: login → profile → logout → profile returns 401 after logout. Also test that a manually crafted (unsigned) cookie value on /profile returns 401.

View sample solution ↗