Sending JSON Responses and HTTP Status Codes

Every HTTP response has two essential parts: a status code that tells the client whether the request succeeded and why, and a body that carries the data. In a MERN REST API the body is always JSON. Getting both right โ€” choosing the correct status code and structuring a consistent JSON response shape โ€” is what separates a professional API from one that leaves your React frontend guessing. In this lesson you will master the Express response object, learn which HTTP status codes belong in which situations, and build a consistent response pattern you will use throughout the series.

HTTP Status Code Groups

Range Category Common Codes
2xx Success 200 OK, 201 Created, 204 No Content
3xx Redirection 301 Moved Permanently, 302 Found
4xx Client Error 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable
5xx Server Error 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable
Note: The distinction between 401 Unauthorized and 403 Forbidden confuses many developers. 401 means “I don’t know who you are โ€” please authenticate.” 403 means “I know who you are but you don’t have permission to do this.” In a MERN JWT app: missing or invalid token โ†’ 401; valid token but insufficient role/ownership โ†’ 403.
Tip: Adopt a consistent JSON response envelope for your entire API and never deviate from it. A common pattern is { success: boolean, data: any, message: string }. Your React frontend can then always check response.data.success and read from response.data.data, regardless of which endpoint it called. Inconsistent response shapes are one of the most frustrating things to work with on the frontend.
Warning: Never return a 200 status code with an error message in the body, such as res.status(200).json({ error: 'Not found' }). This breaks HTTP semantics and forces your React code to inspect the body to determine success instead of using the standard HTTP status code. Axios and fetch both have built-in error handling that triggers on 4xx/5xx status codes โ€” use them correctly.

The Most Important Status Codes for MERN APIs

Code Name When to use in MERN
200 OK Successful GET, PUT, PATCH, DELETE responses
201 Created Successful POST that creates a new resource
204 No Content Successful DELETE with no body to return
400 Bad Request Validation failure โ€” missing or invalid fields in request body
401 Unauthorized Missing, invalid, or expired JWT token
403 Forbidden Valid token but user lacks permission (not owner, not admin)
404 Not Found Requested resource does not exist in MongoDB
409 Conflict Duplicate โ€” e.g. email already registered
422 Unprocessable Entity Well-formed request but semantic validation failed
500 Internal Server Error Unexpected server error โ€” database down, unhandled exception

Express Response Methods

// โ”€โ”€ res.json() โ€” send a JSON response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.json({ success: true, data: post });
// Automatically sets Content-Type: application/json
// Accepts objects, arrays, strings, numbers, booleans, null

// โ”€โ”€ res.status() โ€” set the HTTP status code โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(201).json({ success: true, data: newPost });
res.status(404).json({ success: false, message: 'Post not found' });
res.status(500).json({ success: false, message: 'Internal server error' });

// โ”€โ”€ res.send() โ€” send any response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.send('Hello World');            // text/html by default
res.send(Buffer.from('binary'));     // binary data
// For APIs: always use res.json() instead of res.send()

// โ”€โ”€ res.sendStatus() โ€” send status code only, body is the status text โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.sendStatus(204);    // 204 No Content โ€” body is "No Content"
// Useful for DELETE responses where you have nothing meaningful to return

// โ”€โ”€ res.redirect() โ€” send a redirect response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.redirect(301, 'https://newurl.com');
res.redirect('/api/v2/posts');

// โ”€โ”€ res.set() โ€” manually set headers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.set('X-Request-ID', '12345');
res.set('Cache-Control', 'no-store');

// โ”€โ”€ res.cookie() โ€” set a cookie โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.cookie('refreshToken', token, {
  httpOnly: true,    // not accessible via JavaScript
  secure:   true,    // HTTPS only
  sameSite: 'strict',
  maxAge:   7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
});

Consistent API Response Pattern

// server/src/utils/apiResponse.js
// A helper to keep all responses consistent across the API

const sendSuccess = (res, data, statusCode = 200, message = '') => {
  const response = { success: true };
  if (message) response.message = message;
  if (Array.isArray(data)) response.count = data.length;
  response.data = data;
  return res.status(statusCode).json(response);
};

const sendError = (res, message, statusCode = 500) => {
  return res.status(statusCode).json({ success: false, message });
};

module.exports = { sendSuccess, sendError };

// โ”€โ”€ Using the helpers in route handlers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
const { sendSuccess, sendError } = require('../utils/apiResponse');

app.get('/api/posts', (req, res) => {
  const posts = getAllPosts();
  sendSuccess(res, posts);
  // Response: { success: true, count: 3, data: [...] }
});

app.get('/api/posts/:id', (req, res) => {
  const post = getPostById(req.params.id);
  if (!post) return sendError(res, 'Post not found', 404);
  sendSuccess(res, post);
});

app.post('/api/posts', (req, res) => {
  const { title, body } = req.body;
  if (!title || !body) return sendError(res, 'Title and body are required', 400);
  const newPost = createPost({ title, body });
  sendSuccess(res, newPost, 201, 'Post created successfully');
});

Response Examples โ€” All Scenarios

// โ”€โ”€ 200 โ€” successful GET โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(200).json({ success: true, data: posts });

// โ”€โ”€ 201 โ€” resource created โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(201).json({ success: true, data: newPost, message: 'Post created' });

// โ”€โ”€ 204 โ€” deleted, no content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(204).end(); // no body โ€” do not call res.json() with 204

// โ”€โ”€ 400 โ€” validation failure โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(400).json({ success: false, message: 'Title is required', field: 'title' });

// โ”€โ”€ 401 โ€” not authenticated โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(401).json({ success: false, message: 'No token provided โ€” please log in' });

// โ”€โ”€ 403 โ€” authenticated but not authorised โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(403).json({ success: false, message: 'You do not own this post' });

// โ”€โ”€ 404 โ€” resource not found โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(404).json({ success: false, message: `Post with id ${id} not found` });

// โ”€โ”€ 409 โ€” conflict โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(409).json({ success: false, message: 'Email already registered' });

// โ”€โ”€ 500 โ€” unexpected error โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
res.status(500).json({ success: false, message: 'An unexpected error occurred' });
// In practice: caught by global error middleware โ€” not returned from route handlers

Common Mistakes

Mistake 1 โ€” Always returning 200 regardless of outcome

โŒ Wrong โ€” React has no way to know if something failed:

app.post('/api/auth/login', (req, res) => {
  if (!userFound) return res.json({ success: false, message: 'User not found' }); // status 200!
  // Axios treats this as a success โ†’ React must check success flag manually
});

โœ… Correct โ€” use the appropriate 4xx status code so Axios catches it automatically:

if (!userFound) return res.status(404).json({ success: false, message: 'User not found' });
// Axios throws an error โ†’ React .catch() block handles it โœ“

Mistake 2 โ€” Sending a body with 204 No Content

โŒ Wrong โ€” 204 responses must have no body:

res.status(204).json({ success: true, message: 'Deleted' });
// The json body is silently discarded by the browser โ€” React receives nothing

โœ… Correct โ€” use res.status(204).end() or return 200 with a confirmation body:

res.status(200).json({ success: true, message: 'Post deleted successfully' }); // โœ“

Mistake 3 โ€” Leaking internal error details to the client in production

โŒ Wrong โ€” sending raw error objects or stack traces to the client:

res.status(500).json({ error: err }); // may expose DB credentials, file paths, stack traces

โœ… Correct โ€” log the full error server-side, send a generic message to the client:

console.error(err);
res.status(500).json({ success: false, message: 'An unexpected error occurred' });

Quick Reference

Situation Status Response
GET list or GET one 200 res.json({ success: true, data: ... })
POST โ€” created 201 res.status(201).json({ data: newItem })
DELETE โ€” no body 204 res.status(204).end()
Validation failed 400 res.status(400).json({ message: '...' })
No / bad token 401 res.status(401).json({ message: '...' })
No permission 403 res.status(403).json({ message: '...' })
Resource not found 404 res.status(404).json({ message: '...' })
Duplicate / conflict 409 res.status(409).json({ message: '...' })
Unexpected server error 500 Global error middleware handles this

🧠 Test Yourself

A user tries to delete a post they do not own. The JWT token is valid and the post exists. Which HTTP status code is correct?