Episode 3 — NodeJS MongoDB Backend Architecture / 3.4 — Express JS
3.4.c — Returning Responses
In one sentence: Express's response object (
res) provides a rich set of methods —send(),json(),status(),redirect(),render(),download(), and more — that let you control what the client receives, how it's formatted, and which headers accompany it, forming the backbone of every API and web server you build.
Navigation: <- 3.4.b — Setting Up Express Server | 3.4.d — Query Parameters and URL Parameters ->
1. res.send() — auto content-type detection
res.send() is the Swiss-army knife of Express responses. It auto-detects the content type based on the argument:
// String → text/html
app.get('/html', (req, res) => {
res.send('<h1>Hello World</h1>');
// Content-Type: text/html; charset=utf-8
});
// Object or Array → application/json
app.get('/json', (req, res) => {
res.send({ name: 'Alice', age: 30 });
// Content-Type: application/json; charset=utf-8
});
// Buffer → application/octet-stream
app.get('/buffer', (req, res) => {
res.send(Buffer.from('raw bytes'));
// Content-Type: application/octet-stream
});
// Number → DOES NOT send as body (legacy: treated as status code)
// WRONG: res.send(200) — this sets status, not body
// Use res.sendStatus(200) or res.status(200).send('OK') instead
What res.send() does internally
- Sets
Content-Typeheader based on argument type (if not already set) - Sets
Content-Lengthheader automatically - Handles HEAD requests (sends headers only, no body)
- Sets
ETagheader for caching (auto-generated) - Calls
res.end()— the response is finished aftersend()
2. res.json() — always sends JSON
res.json() always serializes the argument with JSON.stringify() and sets Content-Type: application/json:
// Object
app.get('/api/user', (req, res) => {
res.json({ id: 1, name: 'Alice', email: 'alice@example.com' });
});
// Array
app.get('/api/users', (req, res) => {
res.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
]);
});
// Primitives — still valid JSON
app.get('/api/count', (req, res) => {
res.json(42); // Sends: 42
});
app.get('/api/active', (req, res) => {
res.json(true); // Sends: true
});
app.get('/api/nothing', (req, res) => {
res.json(null); // Sends: null
});
res.send() vs res.json() — which to use?
| Scenario | Use |
|---|---|
| Building an API — returning data | res.json() — intent is explicit |
| Returning HTML string | res.send() |
| Returning a file | res.sendFile() |
Need to return null, false, or a number as JSON | res.json() — handles edge cases correctly |
| Mixed responses in the same app | Be consistent per route type |
Best practice for APIs: Always use res.json(). It makes the intent clear and handles edge cases (null, booleans, numbers) that res.send() may not serialize the same way.
3. res.status(code).json({...}) — chaining status with response
res.status() sets the HTTP status code and returns res, enabling method chaining:
// 200 — OK (default, often omitted)
app.get('/api/users', (req, res) => {
res.status(200).json({ users: [] });
// Equivalent to: res.json({ users: [] });
});
// 201 — Created
app.post('/api/users', (req, res) => {
const newUser = { id: Date.now(), name: req.body.name };
res.status(201).json(newUser);
});
// 400 — Bad Request
app.post('/api/users', (req, res) => {
if (!req.body.name) {
return res.status(400).json({
error: 'Validation failed',
details: [{ field: 'name', message: 'Name is required' }],
});
}
// ... create user ...
});
// 404 — Not Found
app.get('/api/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// 500 — Internal Server Error
app.get('/api/data', (req, res) => {
try {
const data = riskyOperation();
res.json(data);
} catch (err) {
res.status(500).json({ error: 'Something went wrong' });
}
});
Critical: Use return before error responses to prevent code from continuing and sending a second response.
4. res.sendStatus(code) — send status code as response body
res.sendStatus() sets the status code and sends the status text as the response body:
app.delete('/api/users/:id', (req, res) => {
// ... delete user ...
res.sendStatus(204);
// Status: 204, Body: "No Content"
});
app.post('/api/login', (req, res) => {
if (!authorized) {
return res.sendStatus(401);
// Status: 401, Body: "Unauthorized"
}
});
| Method | Status | Body |
|---|---|---|
res.sendStatus(200) | 200 | "OK" |
res.sendStatus(201) | 201 | "Created" |
res.sendStatus(204) | 204 | "No Content" |
res.sendStatus(400) | 400 | "Bad Request" |
res.sendStatus(404) | 404 | "Not Found" |
res.sendStatus(500) | 500 | "Internal Server Error" |
When to use: Quick utility responses where you do not need a custom body.
5. res.redirect(url) — redirecting
// Default 302 (temporary redirect)
app.get('/old-page', (req, res) => {
res.redirect('/new-page');
});
// 301 permanent redirect (SEO — search engines update their index)
app.get('/old-url', (req, res) => {
res.redirect(301, '/new-url');
});
// Redirect to external URL
app.get('/google', (req, res) => {
res.redirect('https://www.google.com');
});
// Redirect back to the previous page (uses Referer header)
app.post('/form-submit', (req, res) => {
// ... process form ...
res.redirect('back');
});
// Redirect with query parameters
app.get('/search-redirect', (req, res) => {
res.redirect(`/results?q=${encodeURIComponent(req.query.term)}`);
});
| Code | Type | When to use |
|---|---|---|
| 301 | Permanent | URL changed forever (SEO) |
| 302 | Found (temporary) | Default; temporary redirect |
| 307 | Temporary (preserves method) | POST redirect that must stay POST |
| 308 | Permanent (preserves method) | Permanent redirect that must stay POST |
6. res.render() — rendering templates (preview)
res.render() renders a template (EJS, Pug, Handlebars) and sends the resulting HTML:
// Setup (done once)
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// Usage — renders views/home.ejs with data
app.get('/', (req, res) => {
res.render('home', {
title: 'Welcome',
user: { name: 'Alice' },
});
});
// views/home.ejs
// <h1><%= title %></h1>
// <p>Hello, <%= user.name %></p>
This is covered in detail in 3.5 — Template Engine EJS. For API-only servers, you may never use res.render().
7. res.download() — triggering file download
const path = require('path');
// Basic download — browser shows "Save As" dialog
app.get('/download/report', (req, res) => {
res.download(path.join(__dirname, 'files', 'report.pdf'));
// Sets Content-Disposition: attachment; filename="report.pdf"
});
// Custom filename in download dialog
app.get('/download/report', (req, res) => {
res.download(
path.join(__dirname, 'files', 'report-2024-q1.pdf'),
'Q1-Report.pdf' // user sees this name
);
});
// With error handling
app.get('/download/:file', (req, res) => {
const filePath = path.join(__dirname, 'files', req.params.file);
res.download(filePath, (err) => {
if (err) {
res.status(404).json({ error: 'File not found' });
}
});
});
res.download() vs res.sendFile()
| Method | Browser behavior |
|---|---|
res.sendFile() | Displays the file inline (if browser can render it) |
res.download() | Prompts download dialog (Content-Disposition: attachment) |
8. res.set() / res.header() — setting custom headers
res.set() and res.header() are aliases — they do the same thing:
// Single header
app.get('/api/data', (req, res) => {
res.set('X-Request-Id', 'abc-123');
res.set('Cache-Control', 'no-store');
res.json({ data: 'hello' });
});
// Multiple headers at once
app.get('/api/data', (req, res) => {
res.set({
'X-Request-Id': 'abc-123',
'X-RateLimit-Remaining': '99',
'Cache-Control': 'public, max-age=3600',
});
res.json({ data: 'hello' });
});
// CORS headers (usually use the `cors` package instead)
app.use((req, res, next) => {
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
res.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
next();
});
Common custom headers
| Header | Purpose |
|---|---|
X-Request-Id | Unique ID for request tracing |
X-RateLimit-Remaining | How many API calls remain |
Cache-Control | Browser/CDN caching behavior |
X-Powered-By | Framework identifier (Express sets this by default — disable in production) |
// Disable X-Powered-By for security (don't reveal your stack)
app.disable('x-powered-by');
9. res.cookie() — setting cookies
// Basic cookie
app.get('/set-cookie', (req, res) => {
res.cookie('username', 'alice');
res.send('Cookie set');
});
// Cookie with options
app.get('/set-cookie', (req, res) => {
res.cookie('sessionId', 'abc123', {
httpOnly: true, // Not accessible via JavaScript (XSS protection)
secure: true, // Only sent over HTTPS
maxAge: 3600000, // 1 hour in milliseconds
sameSite: 'strict', // CSRF protection
path: '/', // Available on all paths
});
res.send('Secure cookie set');
});
// Clear a cookie
app.get('/logout', (req, res) => {
res.clearCookie('sessionId');
res.redirect('/');
});
| Option | What it does |
|---|---|
httpOnly | Cannot be read by document.cookie in browser JS |
secure | Only sent over HTTPS connections |
maxAge | Lifetime in milliseconds |
expires | Explicit expiration date |
sameSite | 'strict', 'lax', or 'none' — CSRF protection |
domain | Which domain receives the cookie |
path | Which URL paths receive the cookie |
Note: To read cookies from incoming requests, you need the cookie-parser middleware:
npm install cookie-parser
const cookieParser = require('cookie-parser');
app.use(cookieParser());
app.get('/profile', (req, res) => {
const sessionId = req.cookies.sessionId;
// ...
});
10. Response best practices: consistent JSON structure
Every API should follow a consistent response format across all endpoints.
Pattern A: Wrapper object
// Success
{
"success": true,
"data": {
"id": 1,
"name": "Alice"
}
}
// Error
{
"success": false,
"error": {
"code": "USER_NOT_FOUND",
"message": "No user found with ID 99"
}
}
Pattern B: Flat with meta (common in larger APIs)
// Success (single resource)
{
"data": {
"id": 1,
"name": "Alice"
}
}
// Success (list with pagination)
{
"data": [
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" }
],
"meta": {
"total": 100,
"page": 1,
"limit": 10,
"totalPages": 10
}
}
// Error
{
"error": {
"status": 404,
"code": "NOT_FOUND",
"message": "User not found"
}
}
Helper functions for consistency
// utils/response.js
function sendSuccess(res, data, statusCode = 200) {
return res.status(statusCode).json({
success: true,
data,
});
}
function sendError(res, message, statusCode = 500, code = 'SERVER_ERROR') {
return res.status(statusCode).json({
success: false,
error: { code, message },
});
}
module.exports = { sendSuccess, sendError };
// Usage in route handlers
const { sendSuccess, sendError } = require('./utils/response');
app.get('/api/users/:id', (req, res) => {
const user = findUser(req.params.id);
if (!user) {
return sendError(res, 'User not found', 404, 'USER_NOT_FOUND');
}
sendSuccess(res, user);
});
app.post('/api/users', (req, res) => {
if (!req.body.name) {
return sendError(res, 'Name is required', 400, 'VALIDATION_ERROR');
}
const user = createUser(req.body);
sendSuccess(res, user, 201);
});
11. Real-world response patterns
Pagination response
app.get('/api/products', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const offset = (page - 1) * limit;
// Simulate database query
const allProducts = getProducts();
const products = allProducts.slice(offset, offset + limit);
res.json({
data: products,
meta: {
total: allProducts.length,
page,
limit,
totalPages: Math.ceil(allProducts.length / limit),
hasNextPage: page * limit < allProducts.length,
hasPrevPage: page > 1,
},
});
});
Conditional response based on Accept header
app.get('/api/users/:id', (req, res) => {
const user = { id: 1, name: 'Alice' };
// Respond based on what the client accepts
res.format({
'application/json': () => {
res.json(user);
},
'text/html': () => {
res.send(`<h1>${user.name}</h1>`);
},
'text/plain': () => {
res.send(`User: ${user.name}`);
},
default: () => {
res.status(406).json({ error: 'Not Acceptable' });
},
});
});
12. Complete response method reference
| Method | Purpose | Sets Content-Type |
|---|---|---|
res.send(body) | Send response (auto-detect type) | Auto |
res.json(obj) | Send JSON response | application/json |
res.status(code) | Set status (chainable) | -- |
res.sendStatus(code) | Set status + send status text as body | text/plain |
res.redirect([code,] url) | Redirect to URL | -- |
res.render(view, data) | Render template | text/html |
res.sendFile(path) | Send file (inline) | Based on extension |
res.download(path) | Send file (attachment) | Based on extension |
res.set(header, value) | Set response header | -- |
res.cookie(name, val) | Set cookie | -- |
res.clearCookie(name) | Remove cookie | -- |
res.type(type) | Set Content-Type shorthand | Given type |
res.format(obj) | Content negotiation | Varies |
res.end() | End response (no body) | -- |
res.append(header, val) | Append to existing header | -- |
13. Key takeaways
res.send()auto-detects content type;res.json()always sends JSON — preferres.json()for APIs.res.status()is chainable —res.status(404).json({...})is the standard pattern.- Use
res.redirect()for URL redirects (301 permanent, 302 temporary). res.download()triggers a file download;res.sendFile()displays inline.- Set custom headers with
res.set()and cookies withres.cookie(). - Standardize your response format across all endpoints — pick a pattern and stick to it.
- Always
returnafter sending error responses to prevent double-send errors.
Explain-It Challenge
Explain without notes:
- What is the difference between
res.send({ x: 1 })andres.json({ x: 1 })? When does it matter? - Why should you always
returnafter callingres.status(400).json(...)in a route handler? - Describe two common JSON response envelope patterns for APIs.
- What does
res.cookie('token', 'abc', { httpOnly: true })protect against?
Navigation: <- 3.4.b — Setting Up Express Server | 3.4.d — Query Parameters and URL Parameters ->