REST (Representational State Transfer) is an architectural style for designing networked APIs. It is not a protocol or a standard — it is a set of constraints and conventions that, when followed consistently, produce APIs that are intuitive to use, easy to document, and straightforward to consume from any client (Angular, mobile, curl, Postman). The difference between a REST API that developers enjoy using and one they dread is almost entirely in how well these conventions are followed. This lesson covers every convention you need to design professional, production-quality REST APIs for the MEAN Stack.
REST Constraints and Key Principles
| Principle | Meaning | Practical Impact |
|---|---|---|
| Stateless | Each request contains all information needed to process it | No server-side sessions — JWT tokens travel with every request |
| Resource-based | URLs identify resources (nouns), HTTP methods describe actions (verbs) | /tasks not /getTasks or /deleteTask |
| Uniform interface | Consistent conventions across all endpoints | All errors have the same shape, all lists are paginated the same way |
| Client-Server | Frontend and backend are independently deployable | Angular can be replaced with React without changing the API |
| Layered System | Client does not know if it talks to the real server or a proxy | Load balancers, CDNs, and gateways are transparent to the client |
URL Naming Conventions
| Rule | Good | Bad |
|---|---|---|
| Use nouns, not verbs | /api/tasks |
/api/getTasks, /api/createTask |
| Use plural nouns | /api/tasks, /api/users |
/api/task, /api/user |
| Use kebab-case | /api/task-categories |
/api/taskCategories, /api/task_categories |
| Nest related resources | /api/users/:id/tasks |
/api/getUserTasks?userId=:id |
| Keep nesting shallow (max 2) | /api/users/:id/tasks |
/api/users/:id/projects/:pid/tasks/:tid/comments |
| Actions via sub-resources | POST /api/tasks/:id/complete |
PUT /api/tasks/:id?action=complete |
| Version in the path | /api/v1/tasks |
/tasks (no version) |
HTTP Method to CRUD Mapping
| Method | Collection /tasks |
Document /tasks/:id |
Idempotent |
|---|---|---|---|
| GET | List all tasks (with filters) | Get one task by ID | Yes |
| POST | Create a new task | N/A (or custom action) | No |
| PUT | Bulk replace (rare) | Replace entire task | Yes |
| PATCH | N/A | Partially update task | No |
| DELETE | Bulk delete (rare) | Delete task by ID | Yes |
HTTP Status Code Quick Reference
| Code | Name | When to Use in a REST API |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH. Also for DELETE if body returned |
| 201 | Created | Successful POST — new resource created. Include resource in body |
| 204 | No Content | Successful DELETE or action with no response body needed |
| 400 | Bad Request | Malformed JSON, missing required fields, validation failure |
| 401 | Unauthorized | No valid authentication token provided |
| 403 | Forbidden | Authenticated but not authorised for this resource |
| 404 | Not Found | Resource does not exist — or deliberately hide existence |
| 409 | Conflict | Duplicate resource — email already registered |
| 422 | Unprocessable Entity | Semantic validation error — valid syntax, invalid logic |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Unhandled server error — never expose stack trace |
{ success: true, data: ... }, list responses add meta: { total, page, limit, totalPages }, and error responses use { success: false, message: '...', errors: [...] }. Angular services that consume these can extract data and handle errors with a single shared pipe./api/v1/. When you need to make breaking changes, introduce /api/v2/ and give consumers time to migrate. Trying to add versioning to an unversioned API after clients are deployed is extremely painful.Complete Task Manager API Design
Task Manager REST API — Complete Endpoint Map
══════════════════════════════════════════════════════════════
Authentication
POST /api/v1/auth/register Create account
POST /api/v1/auth/login Login, receive JWT
POST /api/v1/auth/refresh Refresh access token
POST /api/v1/auth/logout Invalidate refresh token
GET /api/v1/auth/me Get current user profile
PATCH /api/v1/auth/me Update profile
PATCH /api/v1/auth/me/password Change password
Tasks (all protected — require Authorization: Bearer token)
GET /api/v1/tasks List tasks (filter, sort, paginate)
POST /api/v1/tasks Create task
GET /api/v1/tasks/:id Get one task
PUT /api/v1/tasks/:id Replace task
PATCH /api/v1/tasks/:id Partial update
DELETE /api/v1/tasks/:id Delete task
PATCH /api/v1/tasks/:id/complete Mark as complete
PATCH /api/v1/tasks/:id/reopen Reopen completed task
File Uploads
POST /api/v1/tasks/:id/attachments Upload file to task
DELETE /api/v1/tasks/:id/attachments/:fileId Delete attachment
GET /api/v1/uploads/:filename Serve uploaded file
Query String Parameters (GET /api/v1/tasks)
status pending | in-progress | completed
priority low | medium | high
page integer (default: 1)
limit integer (default: 10, max: 100)
sort field name (default: -createdAt)
q search query (searches title and description)
Response Envelope
Success: { "success": true, "data": {...} }
List: { "success": true, "data": [...], "meta": { total, page, limit, totalPages } }
Created: { "success": true, "data": {...} } HTTP 201
Deleted: (empty body) HTTP 204
Error: { "success": false, "message": "...", "errors": [...] }
API Versioning in Express
// ── Option 1: URL path versioning (recommended) ───────────────────────────
const express = require('express');
const app = express();
const v1Router = express.Router();
v1Router.use('/auth', require('./routes/v1/auth.routes'));
v1Router.use('/tasks', require('./routes/v1/task.routes'));
const v2Router = express.Router();
v2Router.use('/auth', require('./routes/v2/auth.routes'));
v2Router.use('/tasks', require('./routes/v2/task.routes'));
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
// ── Option 2: Header versioning ───────────────────────────────────────────
app.use('/api/tasks', (req, res, next) => {
const version = req.get('API-Version') || '1';
if (version === '2') return require('./routes/v2/task.routes')(req, res, next);
require('./routes/v1/task.routes')(req, res, next);
});
// ── Consistent response helpers ───────────────────────────────────────────
// utils/response.js
const success = (res, data, statusCode = 200) =>
res.status(statusCode).json({ success: true, data });
const created = (res, data) =>
res.status(201).json({ success: true, data });
const noContent = (res) =>
res.status(204).end();
const paginated = (res, data, meta) =>
res.json({ success: true, data, meta });
const error = (res, message, statusCode = 400, errors = []) =>
res.status(statusCode).json({ success: false, message, errors });
module.exports = { success, created, noContent, paginated, error };
// ── Usage in a controller ─────────────────────────────────────────────────
const { success, created, noContent, paginated } = require('../utils/response');
const asyncHandler = require('../utils/asyncHandler');
exports.getAll = asyncHandler(async (req, res) => {
const { page = 1, limit = 10 } = req.query;
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(100, parseInt(limit, 10));
const skip = (pageNum - 1) * limitNum;
const [data, total] = await Promise.all([
Task.find({ user: req.user.id }).skip(skip).limit(limitNum).lean(),
Task.countDocuments({ user: req.user.id }),
]);
paginated(res, data, {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
});
});
exports.create = asyncHandler(async (req, res) => {
const task = await Task.create({ ...req.body, user: req.user.id });
created(res, task);
});
exports.remove = asyncHandler(async (req, res) => {
await Task.findOneAndDelete({ _id: req.params.id, user: req.user.id });
noContent(res);
});
How It Works
Step 1 — Resources Are Nouns, Methods Are Verbs
The URL identifies what the action is about (the resource), and the HTTP method identifies what to do with it. DELETE /api/v1/tasks/42 is self-explanatory — delete task 42. GET /api/v1/tasks/42 reads it. PATCH /api/v1/tasks/42 modifies it. This separation lets clients predict URL patterns and lets servers organise code by resource rather than by action.
Step 2 — Nested Resources Express Ownership
/api/v1/users/42/tasks means “tasks belonging to user 42”. This is cleaner than /api/v1/tasks?userId=42 for resources that genuinely own sub-resources. However, keep nesting to a maximum of two levels deep — deeper nesting creates URLs that are difficult to construct and maintain. If you need a deeply nested resource, consider an independent endpoint with filter parameters.
Step 3 — The Response Envelope Creates Consistency
Wrapping every response in { success, data } or { success, message, errors } gives Angular a guaranteed structure. Every HTTP service method can use the same map(res => res.data) operator regardless of which endpoint it calls. Error handling code can always read error.error.message for the human-readable error message. This consistency dramatically reduces frontend boilerplate.
Step 4 — Versioning Protects Existing Clients
Once Angular (or a mobile app, or a third-party integration) depends on your API’s response shape, changing it breaks them. URL versioning (/api/v1/) lets you introduce /api/v2/ with breaking changes while /api/v1/ continues working for existing clients. You can then deprecate v1 after all consumers have migrated. Without versioning, every change is potentially a breaking change.
Step 5 — Idempotency Matters for Safe Retries
Idempotent operations (GET, PUT, DELETE) produce the same result regardless of how many times they are called. Non-idempotent operations (POST) create a new resource each time. Clients can safely retry idempotent requests after network failures without side effects. This is why payment APIs use PUT (idempotent) for charging rather than POST — retrying a failed PUT does not double-charge the customer.
Real-World Example: Full Task Controller
// controllers/task.controller.js
const Task = require('../models/task.model');
const asyncHandler = require('../utils/asyncHandler');
const { NotFoundError, ForbiddenError } = require('../utils/AppError');
const { success, created, noContent, paginated } = require('../utils/response');
const VALID_SORT_FIELDS = new Set([
'createdAt', '-createdAt', 'title', '-title',
'priority', '-priority', 'dueDate', '-dueDate',
]);
// GET /api/v1/tasks
exports.getAll = asyncHandler(async (req, res) => {
const { status, priority, q, page = 1, limit = 10, sort = '-createdAt' } = req.query;
const filter = { user: req.user.id };
if (status) filter.status = status;
if (priority) filter.priority = priority;
if (q) filter.$or = [
{ title: { $regex: q, $options: 'i' } },
{ description: { $regex: q, $options: 'i' } },
];
const pageNum = Math.max(1, parseInt(page, 10));
const limitNum = Math.min(100, parseInt(limit, 10));
const sortStr = VALID_SORT_FIELDS.has(sort) ? sort : '-createdAt';
const [data, total] = await Promise.all([
Task.find(filter)
.sort(sortStr)
.skip((pageNum - 1) * limitNum)
.limit(limitNum)
.select('-__v')
.lean(),
Task.countDocuments(filter),
]);
res.set('X-Total-Count', total);
paginated(res, data, {
total,
page: pageNum,
limit: limitNum,
totalPages: Math.ceil(total / limitNum),
});
});
// GET /api/v1/tasks/:id
exports.getById = asyncHandler(async (req, res) => {
const task = await Task.findOne({ _id: req.params.id, user: req.user.id }).lean();
if (!task) throw new NotFoundError('Task not found');
success(res, task);
});
// POST /api/v1/tasks
exports.create = asyncHandler(async (req, res) => {
const { title, description, priority, dueDate } = req.body;
const task = await Task.create({ title, description, priority, dueDate, user: req.user.id });
created(res, task);
});
// PUT /api/v1/tasks/:id — full replace
exports.replace = asyncHandler(async (req, res) => {
const { title, description, priority, status, dueDate } = req.body;
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, user: req.user.id },
{ title, description, priority, status, dueDate },
{ new: true, runValidators: true, overwrite: true }
);
if (!task) throw new NotFoundError('Task not found');
success(res, task);
});
// PATCH /api/v1/tasks/:id — partial update
exports.update = asyncHandler(async (req, res) => {
const allowed = ['title', 'description', 'priority', 'status', 'dueDate'];
const updates = Object.fromEntries(
Object.entries(req.body).filter(([k]) => allowed.includes(k))
);
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, user: req.user.id },
{ $set: updates },
{ new: true, runValidators: true }
);
if (!task) throw new NotFoundError('Task not found');
success(res, task);
});
// DELETE /api/v1/tasks/:id
exports.remove = asyncHandler(async (req, res) => {
const task = await Task.findOneAndDelete({ _id: req.params.id, user: req.user.id });
if (!task) throw new NotFoundError('Task not found');
noContent(res);
});
// PATCH /api/v1/tasks/:id/complete
exports.markComplete = asyncHandler(async (req, res) => {
const task = await Task.findOneAndUpdate(
{ _id: req.params.id, user: req.user.id },
{ $set: { status: 'completed', completedAt: new Date() } },
{ new: true }
);
if (!task) throw new NotFoundError('Task not found');
success(res, task);
});
Common Mistakes
Mistake 1 — Using verbs in URLs
❌ Wrong — RPC-style URLs mix action into the path:
app.post('/api/createTask', handler);
app.get( '/api/getTaskById', handler);
app.post('/api/deleteTask', handler);
app.post('/api/completeTask', handler);
✅ Correct — nouns in URLs, HTTP method carries the action:
app.post( '/api/v1/tasks', create);
app.get( '/api/v1/tasks/:id', getById);
app.delete('/api/v1/tasks/:id', remove);
app.patch( '/api/v1/tasks/:id/complete', markComplete);
Mistake 2 — Inconsistent response shapes across endpoints
❌ Wrong — every endpoint returns a different structure:
res.json(task); // GET /tasks/:id — raw document
res.json({ tasks: [...], count: 5 }); // GET /tasks — different wrapper
res.json({ result: 'ok', id: '...' }); // POST /tasks — yet another shape
✅ Correct — use a shared response utility for every endpoint:
success(res, task); // { success: true, data: task }
paginated(res, tasks, { total, page, ... }); // { success: true, data: [...], meta: {...} }
created(res, task); // 201 { success: true, data: task }
Mistake 3 — Not versioning the API from the start
❌ Wrong — no versioning, future breaking changes will break all consumers:
app.use('/api/tasks', taskRoutes); // all clients depend on this exact shape
// 6 months later: rename 'title' to 'name' — breaks every Angular component
✅ Correct — version from day one:
app.use('/api/v1/tasks', taskRoutesV1);
// Later: introduce v2 with renamed fields
app.use('/api/v2/tasks', taskRoutesV2); // v1 continues working for existing clients
Quick Reference
| Operation | Method | URL | Success Status | Body |
|---|---|---|---|---|
| List | GET | /api/v1/tasks |
200 | Array + meta |
| Get one | GET | /api/v1/tasks/:id |
200 | Object |
| Create | POST | /api/v1/tasks |
201 | New object |
| Full replace | PUT | /api/v1/tasks/:id |
200 | Updated object |
| Partial update | PATCH | /api/v1/tasks/:id |
200 | Updated object |
| Delete | DELETE | /api/v1/tasks/:id |
204 | None |
| Custom action | POST/PATCH | /api/v1/tasks/:id/complete |
200 | Updated object |