REST API Design
REST is an architectural style, not a protocol. The goal isn't strict compliance — it's building APIs that are predictable, self-consistent, and a pleasure for other developers to consume.
REST Constraints — What They Actually Mean
REST defines 6 constraints. The ones that matter most in practice:
- Stateless — each request contains all information needed. The server holds no session state. Auth tokens, not server sessions.
- Uniform Interface — resources are identified by URLs; HTTP methods describe the action; responses are self-describing (Content-Type header).
- Client-Server — the client and server are independent. The API is the contract. Change the server implementation without changing clients.
- Cacheable — responses should indicate whether they can be cached. GET responses for public data should be cacheable.
URL Design Rules
/users not /getUsers or /createUser./users not /user. Collections are plural. Single resources are accessed via /users/:id./blog-posts not /blogPosts or /blog_posts. URLs are case-sensitive; lowercase prevents errors./users not /users/. Trailing slashes cause redirect issues and cache misses./users/42/posts for posts owned by user 42. But never more than 2 levels deep — flatten beyond that./api/v1/users. Always. You will need v2 someday. Starting without versioning means a painful migration later.createdAt, userId). Snake_case is common in databases — transform at the API boundary.GET /getUsers
POST /createUser
GET /deleteUser?id=42
POST /user/update/42
GET /UserProfile/Me
GET /users/get-active-onesGET /api/v1/users
POST /api/v1/users
DELETE /api/v1/users/42
PATCH /api/v1/users/42
GET /api/v1/users/me
GET /api/v1/users?active=trueFull CRUD Mapping
| Method | URL | Action | Body | Success | Errors |
|---|---|---|---|---|---|
GET | /users | List (paginated) | — | 200 + array | 400 |
GET | /users/:id | Get one | — | 200 + object | 404 |
POST | /users | Create | New data | 201 + object + Location header | 400, 409 |
PATCH | /users/:id | Partial update | Changed fields only | 200 + updated object | 400, 404, 409 |
PUT | /users/:id | Full replace | Complete object | 200 + replaced object | 400, 404 |
DELETE | /users/:id | Delete | — | 204 empty | 404 |
Consistent Response Envelope
Adopt a consistent shape and never deviate. Clients and frontend teams will thank you. Here's a widely used convention:
// Single resource
{
"data": { "id": 42, "name": "Alice", "email": "alice@example.com" }
}
// Collection with pagination metadata
{
"data": [{ "id": 1 }, { "id": 2 }],
"meta": {
"total": 243,
"page": 2,
"limit": 20,
"totalPages": 13,
"hasNext": true,
"hasPrev": true
}
}
// Error — always the same shape regardless of error type
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body is invalid",
"requestId": "3f2a1b4c-...",
"details": {
"email": ["Invalid email format"],
"password": ["Minimum 8 characters required"]
}
}
}
Pagination: Offset vs Cursor
// ── Offset Pagination ─────────────────────────────────────────────
// Simple. Works for most admin UIs and dashboards.
// Problem: if items are inserted/deleted between pages, you get duplicates/skips.
GET /posts?page=3&limit=20
// Server-side implementation
const offset = (page - 1) * limit;
const posts = await db.post.findMany({ skip: offset, take: limit });
const total = await db.post.count();
// ── Cursor Pagination ─────────────────────────────────────────────
// Stable under inserts/deletes. Required for infinite scroll and real-time feeds.
// Cursor is an opaque value (base64-encoded) pointing to the last seen item.
GET /posts?limit=20&after=eyJpZCI6NDJ9
// Server-side implementation
const cursor = req.query.after
? JSON.parse(Buffer.from(req.query.after, 'base64').toString())
: undefined;
const posts = await db.post.findMany({
take: limit,
skip: cursor ? 1 : 0, // skip the cursor item itself
cursor: cursor ? { id: cursor.id } : undefined,
orderBy: { id: 'asc' },
});
const nextCursor = posts.length === limit
? Buffer.from(JSON.stringify({ id: posts.at(-1).id })).toString('base64')
: null;
res.json({ data: posts, meta: { nextCursor } });
Filtering, Sorting, Sparse Fieldsets
// Filtering — query params map to WHERE conditions
GET /users?role=admin&active=true&createdAfter=2024-01-01
// Sorting
GET /users?sort=createdAt&order=desc
GET /users?sort=-createdAt // minus prefix = desc (alternative)
// Field selection — reduce payload size
GET /users?fields=id,name,email // return only these fields
// Related resource inclusion — avoid N+1 by fetching in one call
GET /users/42?include=posts,comments
// Complex filters (for advanced APIs)
GET /products?price[gte]=10&price[lte]=100&category=electronics
Actions That Don't Fit CRUD
// ✅ Use sub-resource noun endpoints for state transitions
POST /orders/42/cancellation // creates a "cancellation" on the order
DELETE /orders/42/cancellation // removes it (uncancel)
// ✅ Or use action verbs under the resource (pragmatic and widely used)
POST /orders/42/cancel
POST /orders/42/ship
POST /orders/42/refund
POST /users/42/activate
POST /users/42/deactivate
POST /posts/99/publish
// ✅ Non-resource operational endpoints are fine
POST /auth/login
POST /auth/logout
POST /auth/refresh
POST /auth/forgot-password
POST /auth/reset-password
POST /webhooks/stripe // receive external webhooks
GET /health // health check endpoint
GET /metrics // Prometheus metrics endpoint
Idempotency — Critical for Reliability
Idempotent operations can be safely retried. This matters when networks fail and clients don't know if their request was received:
// ✅ Make POST idempotent with Idempotency-Key header
// Client generates a UUID for each logical operation and sends it
// Server stores (key → response) and returns the cached response on retry
POST /payments
Idempotency-Key: a6f3b2c1-...
// Server implementation
const idempotent = (handler) => async (req, res) => {
const key = req.get('Idempotency-Key');
if (key) {
const cached = await redis.get(`idempotency:${key}`);
if (cached) return res.json(JSON.parse(cached));
}
const result = await handler(req);
if (key) await redis.setex(`idempotency:${key}`, 86400, JSON.stringify(result));
res.json(result);
};
HATEOAS — Self-Describing APIs
HATEOAS (Hypermedia As The Engine Of Application State) embeds links to related actions in responses. Clients don't need to construct URLs — they follow links. Most production REST APIs include a pragmatic subset:
{
"data": { "id": 42, "status": "pending" },
"links": {
"self": "/api/v1/orders/42",
"cancel": "/api/v1/orders/42/cancel",
"items": "/api/v1/orders/42/items",
"user": "/api/v1/users/7"
}
}
Health & Observability Endpoints
// GET /health — basic liveness (is the process running?)
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
// GET /ready — readiness (can the app serve traffic? DB connected?)
app.get('/ready', async (req, res) => {
try {
await db.$queryRaw`SELECT 1`; // verify DB connection
res.json({ status: 'ready', db: 'connected' });
} catch {
res.status(503).json({ status: 'not ready', db: 'disconnected' });
}
});
🧠 Check Your Understanding
Go Deeper
Primary source: restfulapi.net — comprehensive REST reference.
Read: Stripe API Docs — the gold standard for REST API design. Study their error shapes, pagination, and idempotency keys.
Ask your teacher: "Design the full API for a multi-tenant SaaS with organisations, members, projects, and tasks."