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)

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. 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:

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

// 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:

  1. GET /api/v1/articles — list articles with offset pagination, returns data[], meta: { total, offset, limit, hasMore }
  2. POST /api/v1/articles — create an article, requires title (min 3 chars) and content (min 10 chars)
  3. GET /api/v1/articles/:id — single article with ETag header, supports conditional GET (If-None-Match)
  4. PUT /api/v1/articles/:id — replace an article (full update)
  5. DELETE /api/v1/articles/:id — delete an article (idempotent: deleting twice returns 204, not 404)
  6. GET /api/v1/articles/:id/comments — list comments on an article
  7. All error responses use the canonical envelope { error: { code, message, details? } }
  8. Include an X-Request-Id response header generated with crypto.randomUUID()

Store data in an in-memory array. Do not use a database. Implement all 7 routes.

5. Consolidation & Key Invariants

6. Next Steps


Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse