REST API Design — Resources, CRUD, and Conventions
Status: Active | Last Updated: 2026-08-29 Category: Backend — API Design Prerequisites: HTTP basics Tags: rest, api, crud, idempotency, pagination, openapi Estimated Time: 4-5 hours (Self-paced, includes lab time)
Summary
REST (Representational State Transfer) is a style for designing HTTP APIs. This article covers the core principles: resources as URLs, HTTP methods as verbs, idempotency, pagination, error formats, and versioning. Follow these patterns and your API is predictable and easy to consume — clients can make reasonable assumptions without reading your code.
What You'll Learn (Core Competencies)
- Model data as resources identified by URLs (nouns), using HTTP methods as verbs
- Implement idempotent GET, PUT, DELETE and non-idempotent POST with correct semantics
- Design pagination with both offset and cursor strategies, choosing the right one for the dataset
- Return structured error responses with consistent error envelopes and appropriate status codes
- Version an API and document it with OpenAPI
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- Next Steps
1. Architectural Overview & Core Schema
REST relies on six architectural constraints: client-server, stateless, cacheable, uniform interface, layered system, and code-on-demand. In practice, this means:
- Stateless: every request is independent. The server does not store session state between requests. Every request carries everything the server needs (auth token, user context).
- Cacheable: responses include cache headers (
Cache-Control,ETag) so clients and proxies can store and reuse responses. - Uniform interface: resources are identified by URIs, manipulated through representations (JSON), and messages are self-descriptive with media types.
- Layered system: proxies, load balancers, and gateways can be inserted without clients knowing.
A well-designed REST API uses nouns for resources, not verbs in URLs. The HTTP method is the verb:
GET /api/users → list users
POST /api/users → create a user
GET /api/users/42 → read user 42
PUT /api/users/42 → replace user 42
PATCH /api/users/42 → partially update user 42
DELETE /api/users/42 → delete user 42
GET /api/users/42/orders → list orders by user 42
Avoid verbs in URLs (/api/getUsers, /api/createPost). The HTTP method is the verb.
2. Deep Dive & Implementation
Idempotency
Idempotent methods can be called multiple times with the same result:
| Method | Idempotent | Notes |
|---|---|---|
| GET | Yes | Reads are always idempotent |
| PUT | Yes | Replacing with same data has same effect |
| DELETE | Yes | Deleting an absent resource is a no-op |
| POST | No | Each call typically creates a new resource |
| PATCH | Sometimes | Depends on the operation type |
For non-idempotent operations (payments, file uploads), use idempotency keys so retries do not double-charge:
app.post("/payments", async (req, res) => {
const key = req.headers["idempotency-key"];
if (!key) return res.status(400).json({ error: "Idempotency-Key required" });
const cached = await redis.get(`idemp:${key}`);
if (cached) return res.status(200).json(JSON.parse(cached));
const result = await charge(req.body);
await redis.setex(`idemp:${key}`, 86400, JSON.stringify(result));
res.status(201).json(result);
});
PATCH idempotency depends on the operation: adding a set member is idempotent; appending to a list is not. Document clearly.
Pagination
Offset-based (?offset=20&limit=10): Good for stable, ordered lists where users might jump to a specific page. Always include total count metadata.
const offset = parseInt(url.searchParams.get("offset") ?? "0", 10);
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "10", 10), 100);
const posts = await prisma.post.findMany({
skip: offset,
take: limit,
orderBy: { createdAt: "desc" }
});
const total = await prisma.post.count();
return Response.json({
data: posts,
meta: { offset, limit, total, hasMore: offset + limit < total }
});
Cursor-based (?cursor=abc&limit=10): Good for large or dynamically changing datasets. The cursor encodes the last seen item's position (usually an ID or timestamp). More efficient for deep pagination.
const cursor = url.searchParams.get("cursor");
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "10", 10), 100);
const posts = await prisma.post.findMany({
take: limit + 1,
...(cursor ? { cursor: { id: parseInt(cursor, 10) }, skip: 1 } : {}),
orderBy: { id: "desc" }
});
const hasNext = posts.length > limit;
const data = hasNext ? posts.slice(0, -1) : posts;
const nextCursor = hasNext ? String(data[data.length - 1].id) : null;
return Response.json({ data, nextCursor });
Cache-Friendly Responses with ETag
app.get("/api/users/:id", async (req, res) => {
const user = await db.getUser(req.params.id);
if (!user) return res.status(404).end();
const etag = `"${hash(user)}"`;
if (req.headers["if-none-match"] === etag) return res.status(304).end();
res.set("ETag", etag).set("Cache-Control", "private, max-age=60").json(user);
});
Error Response Standards
Use consistent error envelopes so clients can parse automatically:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Email must be a valid address",
"details": [{ "field": "email", "issue": "invalid_format" }],
"requestId": "req-9f8e"
}
}
Status code mapping: 400 for validation errors, 401 for unauthenticated, 403 for forbidden, 404 for not found, 409 for conflict, 422 for unprocessable entity, 429 for rate limited, 500 for server errors. Never return 200 with an error body.
URL Design Rules
- Nouns only, plural collections:
/users,/posts - Sub-resources express relationships:
/users/42/orders/7 - Filtering via query parameters:
/users?role=admin&status=active - Use kebab-case:
/user-profiles,/order-items - Lowercase paths:
/api/users, not/api/users - Version in the path:
/api/v1/users
// Good
GET /api/v2/users?role=admin&page=2
GET /api/v2/users/42/orders
// Bad
GET /api/getUsersByRole?role=admin
POST /api/users/42/createOrder
Guided Checkpoint
Test the REST API with a complete request suite:
# Start: bun run server.ts
# List users — returns 200 with array and meta
curl -s http://localhost:3000/api/users | jq .
# Pagination — offset and limit
curl -s "http://localhost:3000/api/users?offset=0&limit=2" | jq .
# Create user — returns 201 with created resource
curl -s -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Alice","email":"alice@example.com"}' | jq .
# Get user — returns 200 with ETag header
curl -s -I http://localhost:3000/api/users/1 | grep -i etag
# Conditional GET — returns 304 when ETag matches
ETAG='"abc123"'
curl -s -I http://localhost:3000/api/users/1 \
-H "If-None-Match: $ETAG" | grep -i http
# Idempotency key — duplicate POST returns same result
curl -s -X POST http://localhost:3000/api/payments \
-H "Content-Type: application/json" \
-H "Idempotency-Key: payment-001" \
-d '{"amount": 100}' | jq .
# Error response — correct envelope and status
curl -s -w "\n%{http_code}" http://localhost:3000/api/users/999 | jq .
All responses must have correct status codes. Error responses must include the error envelope. The idempotency key test must return the same result for the same key.
3. Anti-Patterns & Common Pitfalls
Using verbs in URLs. /api/getUsers and /api/createPost ignore HTTP methods. A client cannot guess what POST /api/getUsers means. The URL is the resource; the method is the operation.
Returning 200 OK for errors. If a client sends invalid input and the server returns 200 with { error: "..." } in the body, clients that use status codes to route logic will treat it as success. Always use the correct 4xx status code.
Using offset pagination on large tables. OFFSET 100000 tells the database to read and discard 100,000 rows. This gets slower as the offset grows. Use cursor-based pagination for large datasets or any dataset that changes frequently.
Not validating idempotency keys. If POST endpoints don't check for duplicate idempotency keys, retries over an unreliable network create duplicate resources (duplicate charges, duplicate records). Always store the key and return the cached response.
Omitting Cache-Control and ETag on read endpoints. Without caching headers, clients and proxies cache responses unpredictably. Adding ETag with conditional 304 responses reduces load and improves latency for repeated reads.
Inconsistent error envelopes. If some errors return { error: "..." } and others return { message: "..." }, every client must handle multiple shapes. Pick one envelope and use it everywhere.
Using POST for everything. Using POST for reads (POST /search) and updates (POST /users/42) works but loses the semantic clarity of GET and PUT/PATCH. It also breaks browser caching and makes logs harder to interpret.
4. Independent Challenge
Design a paginated REST API for a blog with the following requirements:
GET /api/v1/articles— list articles with offset pagination, returnsdata[],meta: { total, offset, limit, hasMore }POST /api/v1/articles— create an article, requirestitle(min 3 chars) andcontent(min 10 chars)GET /api/v1/articles/:id— single article withETagheader, supports conditional GET (If-None-Match)PUT /api/v1/articles/:id— replace an article (full update)DELETE /api/v1/articles/:id— delete an article (idempotent: deleting twice returns204, not404)GET /api/v1/articles/:id/comments— list comments on an article- All error responses use the canonical envelope
{ error: { code, message, details? } } - Include an
X-Request-Idresponse header generated withcrypto.randomUUID()
Store data in an in-memory array. Do not use a database. Implement all 7 routes.
5. Consolidation & Key Invariants
- URLs are resources (nouns):
/users, not/getUsers; HTTP method is the verb - Return the correct status code for every outcome:
201for create,204for delete,400for validation errors,404for missing resources,500for server errors - Never return
200with an error body; clients using status codes will mishandle the response - Use cursor-based pagination for large or frequently-updated datasets; offset-based for stable, ordered lists with known total counts
- Return
ETagand handleIf-None-Matchon all read endpoints; this reduces load and improves client performance - Use idempotency keys on non-idempotent POST operations (payments, uploads) to safely handle retries
- Use one consistent error envelope everywhere:
{ error: { code, message, details? } } - Version APIs in the path (
/api/v1/) for clarity and client compatibility
6. Next Steps
- Implement OpenAPI (Swagger) documentation for your REST API endpoints.
- Add rate limiting middleware (
kb/backend/production-backend.md) to protect POST and DELETE endpoints. - Set up ETag caching and conditional requests for high-traffic read endpoints.
- Implement cursor pagination with a real dataset to compare performance against offset pagination.
- Add refresh token rotation for long-lived sessions (
kb/backend/authentication-patterns.md).
Change Log
- 2026-08-29: Initial scaffold
- 2026-08-29: Expanded REST principles, URL design, idempotency patterns, error standards, code examples added
- 2026-08-29: Migrated to 6-section canonical template (2026-08-29)