Key Points
- Name resources with nouns and plural forms (
/users,/orders/{id}), never verbs (/getUser,/createOrder). - Match HTTP methods to their RFC-defined semantics — GET is safe and idempotent, POST is neither, PATCH is partial update.
- Return RFC 7807 Problem Details for all error responses — a consistent, machine-readable error shape your consumers can rely on.
- Use URL path versioning (
/v1/) — it is visible, cacheable, and debuggable without header inspection. - Cursor-based pagination scales to any dataset size; offset-based pagination breaks under concurrent writes.
Introduction
An API that ignores HTTP semantics, uses verbs in URLs, and returns { "error": "something went wrong" } is technically functional. It also generates a steady stream of support tickets, misconfigured clients, and integration bugs that consume engineering time for as long as the API exists.
REST is not a standard — it is a set of architectural constraints described by Roy Fielding in 2000. Most APIs that call themselves RESTful violate several of those constraints and still work fine. The practices in this article are not about philosophical purity. They are about making choices that reduce friction for the developers consuming your API, reduce bugs at integration points, and reduce maintenance burden over a long API lifetime.
I've shipped and maintained REST APIs across enough companies to know that the decisions you make in week one are the ones you're still apologizing for in year three. This article is the set of defaults I'd apply if I were starting a new API today — with a reference implementation that shows all of them working together.
Architecture Overview
Diagram: Request flow from client through API Gateway, token validation, business logic, and data layer.
Resource Design: Naming and Hierarchy
Resources are the nouns in your API. Every URL identifies a resource, not an action.
Rule: Use plural nouns for collections, singular for specific members.
GET /v1/articles → list all articles
POST /v1/articles → create an article
GET /v1/articles/{id} → get one article
PATCH /v1/articles/{id} → partially update one article
DELETE /v1/articles/{id} → delete one article
Never use verbs in paths:
❌ /v1/getArticle
❌ /v1/createArticle
❌ /v1/articles/delete
✓ /v1/articles/{id} with DELETE method
For sub-resources, express ownership through hierarchy — but stop at two levels. Deeper than two levels signals the resource model needs rethinking.
GET /v1/articles/{id}/comments ← acceptable
GET /v1/articles/{id}/comments/{cid}/replies/{rid} ← too deep, model differently
Resource Data Model
HTTP Methods and Status Codes
Method Semantics
| Method | Safe | Idempotent | Body | When to use |
|---|---|---|---|---|
GET |
Yes | Yes | No | Fetch a resource or collection. Never mutate. |
POST |
No | No | Yes | Create a new resource. Each call may produce a new resource. |
PUT |
No | Yes | Yes | Replace a resource completely. Must send the full representation. |
PATCH |
No | No | Yes | Partial update. Send only the fields to change. |
DELETE |
No | Yes | No | Remove a resource. Idempotent — deleting twice is not an error. |
Safe means the method must not change server state.
Idempotent means calling it N times has the same effect as calling it once.
Use PATCH over PUT for updates unless you genuinely need full replacement. Requiring clients to send the entire resource for a title change is wasteful and error-prone.
Status Codes That Matter
| Code | Meaning | When |
|---|---|---|
200 OK |
Success with body | GET, PATCH, PUT responses |
201 Created |
Resource created | POST success — include Location header |
204 No Content |
Success, no body | DELETE success |
400 Bad Request |
Malformed request | Missing required field, invalid format |
401 Unauthorized |
Not authenticated | No token or invalid token |
403 Forbidden |
Authenticated but not allowed | Correct token, wrong permissions |
404 Not Found |
Resource does not exist | ID not in database |
409 Conflict |
State conflict | Duplicate create, optimistic lock failure |
422 Unprocessable Entity |
Valid format, invalid content | Passes JSON parse but fails business validation |
429 Too Many Requests |
Rate limit hit | Include Retry-After header |
500 Internal Server Error |
Server bug | Never expose stack traces |
Do not return 200 OK with an error payload. A client that checks the status code first — which all good clients do — will miss your error.
Request-Response Sequence
Response Shape: Data and Errors
Success Response
Keep success responses flat when possible. Wrap in a data envelope only when you need top-level metadata alongside the resource (e.g., pagination).
// Single resource — no wrapper needed
{
"id": "abc123",
"title": "REST API Design Best Practices",
"authorId": "usr_789",
"status": "published",
"createdAt": "2026-10-01T09:00:00Z",
"updatedAt": "2026-10-01T09:00:00Z"
}
// Collection — wrapper needed for pagination metadata
{
"data": [...],
"pagination": {
"limit": 20,
"next_cursor": "abc124"
}
}
Use ISO 8601 for all timestamps with UTC timezone (Z suffix). Never return Unix epoch integers — they require client-side conversion and obscure the value during debugging.
Error Response — RFC 7807 Problem Details
RFC 7807 defines a standard error response format. Use it. Your consumers can write error handling once and it works across all your endpoints.
{
"type": "https://api.example.com/errors/not-found",
"title": "Article Not Found",
"status": 404,
"detail": "No article with id 'abc999' exists.",
"instance": "/v1/articles/abc999"
}
Set Content-Type: application/problem+json on all error responses.
import { Response } from 'express';
function problem(
res: Response,
status: number,
title: string,
detail: string,
instance?: string,
): void {
const body: Record<string, unknown> = {
type: `https://api.example.com/errors/${status}`,
title,
status,
detail,
};
if (instance) body.instance = instance;
res.status(status).contentType('application/problem+json').json(body);
}
// Usage
problem(res, 404, 'Article Not Found', `No article with id '${id}' exists.`, req.path);
Versioning
You will change your API. Version it from day one.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v1/articles |
Visible, cacheable, loggable, bookmarkable | URL "leaks" version — semantic purists object |
| Request header | API-Version: 2026-01-01 |
Clean URLs | Invisible, hard to test in browser, can't cache differently |
| Query param | /articles?version=1 |
Easy to test | Pollutes query space, gets stripped by some proxies |
| Accept header | Accept: application/vnd.api.v2+json |
RFC-compliant content negotiation | Verbose, poor tooling support |
Use URL path versioning. You'll hear that it violates REST semantics — typically from people who've never had to track down a versioning bug in production logs where the client was silently sending the wrong header. Visibility in logs, cache keys, and browser testability are real, daily benefits. The semantic purity objection is theoretical.
State Diagram: API Version Lifecycle
Announce deprecation in response headers: Deprecation: true, Sunset: Sat, 01 Jan 2028 00:00:00 GMT.
Pagination and Filtering
Cursor vs Offset
| Criteria | Offset (?page=2&limit=20) |
Cursor (?cursor=abc&limit=20) |
|---|---|---|
| Performance at scale | Degrades — OFFSET 1000000 is slow |
Constant — indexed seek |
| Stable under concurrent writes | No — items shift between pages | Yes — cursor anchors position |
| Seekable to arbitrary page | Yes | No — must walk forward |
| Implementation complexity | Low | Medium |
Use cursor pagination. OFFSET 100000 is a slow table scan dressed up as a query parameter. I've seen this exact pattern take down a read replica during a traffic spike — the query plan looked fine in development on 5,000 rows, then became catastrophic at 2 million under load. Offset pagination is fine for 50-row config lists that never change. For anything that grows or updates frequently, use cursor pagination from day one. Retrofitting it later is painful.
app.get('/v1/articles', (req: Request, res: Response) => {
const limit = Math.min(parseInt((req.query.limit as string) ?? '20', 10), 100);
const cursor = req.query.cursor as string | undefined;
const authorId = req.query.author_id as string | undefined;
const status = req.query.status as string | undefined;
let items = Array.from(db.values());
if (authorId) items = items.filter(a => a.authorId === authorId);
if (status) items = items.filter(a => a.status === status);
let start = 0;
if (cursor) {
const idx = items.findIndex(a => a.id === cursor);
if (idx !== -1) start = idx + 1;
}
const page = items.slice(start, start + limit);
const nextCursor = page.length === limit ? page[page.length - 1].id : null;
res.json({ data: page, pagination: { limit, next_cursor: nextCursor } });
});
Filtering goes in query parameters. Sorting too:
GET /v1/articles?status=published&author_id=usr_789&sort=-createdAt&limit=20
Convention: prefix - means descending (-created_at = newest first).
Security Fundamentals
Authentication
Pass credentials in the Authorization header. Never in URLs — URLs appear in server logs, browser history, and proxy caches.
# Correct
curl -H "Authorization: Bearer $TOKEN" https://api.example.com/v1/articles
# Wrong — token in logs forever
curl https://api.example.com/v1/articles?token=$TOKEN
Request Authentication Flow
HTTPS Only
Enforce HTTPS at the gateway level. Reject HTTP with 301 Moved Permanently to the HTTPS equivalent. Never accept credentials over plain HTTP.
Rate Limiting Response
When you rate-limit a request, return 429 Too Many Requests with these headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1727780460
Idempotency Keys
POST is not idempotent — calling it twice creates two resources. Networks are unreliable. A client that retries a timed-out POST /v1/orders could create a duplicate charge.
The solution: clients attach a unique Idempotency-Key header. The server stores the response for that key. If the same key arrives again, return the cached response without re-executing the operation.
# First request — creates the article
curl -X POST https://api.example.com/v1/articles \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-H "Content-Type: application/json" \
-d '{"title":"Hello","content":"...","authorId":"usr_1"}'
# → 201 Created
# Retry with same key (network timeout, client retries)
curl -X POST https://api.example.com/v1/articles \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
...
# → 201 Created (same response, no second article created)
// Middleware: check and store idempotency keys
// Use Redis with a TTL (e.g., 24 h) in production — Map is in-memory only
const idempotencyStore = new Map<string, { status: number; body: unknown }>();
function withIdempotency(
handler: (req: Request, res: Response) => void,
): (req: Request, res: Response) => void {
return (req, res) => {
const key = req.headers['idempotency-key'] as string | undefined;
if (key) {
const cached = idempotencyStore.get(key);
if (cached) {
res.status(cached.status).json(cached.body);
return;
}
const originalJson = res.json.bind(res);
res.json = (body) => {
idempotencyStore.set(key, { status: res.statusCode, body });
return originalJson(body);
};
}
handler(req, res);
};
}
// Apply to any non-idempotent route
app.post('/v1/articles', withIdempotency((req, res) => {
// ... normal create logic
}));
Idempotency keys matter most for payment, order, and email-send endpoints. Stripe, PayPal, and Shopify all implement this pattern. Your key store needs a TTL — a key that lives forever leaks memory; 24 hours is the Stripe standard.
Common Mistakes
Mistake 1: Verbs in resource names
POST /v1/sendEmail treats the action as the resource. The resource is the email — the action is the HTTP method. POST /v1/emails is correct. If an action genuinely has no resource noun, use a sub-resource: POST /v1/articles/{id}/publish.
Mistake 2: Returning 200 OK for errors
Returning { "success": false, "error": "not found" } with 200 OK means clients must parse and inspect the body on every response to detect failures. HTTP status codes exist precisely to avoid this. Use them.
Mistake 3: Swallowing 404 vs 403
Returning 404 Not Found when the resource exists but the caller lacks access is both a correctness bug and a security vulnerability — you're leaking resource existence to unauthorized callers. The correct response is 403 Forbidden. Return 404 only when the resource genuinely does not exist (or when you deliberately want to hide its existence — which must be an intentional, documented choice, not an accident).
Mistake 4: Offset pagination in high-traffic collections
SELECT * FROM articles ORDER BY created_at LIMIT 20 OFFSET 10000 requires the database to read and discard 10,000 rows. I've debugged production incidents caused by exactly this — the query looks fine on 5,000 rows in development, then becomes a full table scan in disguise at 2 million rows under peak load. Switch to cursor-based pagination: WHERE id > $cursor ORDER BY id LIMIT 20.
Mistake 5: No consistent error shape
An API that returns { "message": "..." } on some errors, { "error": { "code": "...", "description": "..." } } on others, and a plain HTML 500 page on unhandled exceptions forces consumers to write separate error handling for every possible failure mode. Adopt RFC 7807 and return the same shape everywhere — including unhandled 500s.
Full Example: Complete Express TypeScript Implementation
Everything covered above in one runnable file. Copy it, run npm run dev, and use the curl commands below to verify each behavior.
import express, { Request, Response } from 'express';
import { z } from 'zod';
import { randomUUID } from 'crypto';
const app = express();
app.use(express.json());
// Types
interface Article {
id: string;
title: string;
content: string;
authorId: string;
status: 'draft' | 'published';
createdAt: string;
updatedAt: string;
}
const db = new Map<string, Article>();
// Helpers
function nowIso(): string {
return new Date().toISOString();
}
function problem(res: Response, status: number, title: string, detail: string, instance?: string): void {
const body: Record<string, unknown> = {
type: `https://api.example.com/errors/${status}`,
title,
status,
detail,
};
if (instance) body.instance = instance;
res.status(status).contentType('application/problem+json').json(body);
}
// Validation schemas
const ArticleCreateSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
authorId: z.string().min(1),
});
const ArticleUpdateSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().min(1).optional(),
});
// Routes
// POST /v1/articles — create, 201 + Location header
app.post('/v1/articles', (req: Request, res: Response) => {
const parsed = ArticleCreateSchema.safeParse(req.body);
if (!parsed.success) {
problem(res, 422, 'Validation Error', parsed.error.issues.map(i => i.message).join(', '));
return;
}
const id = randomUUID();
const ts = nowIso();
const article: Article = { id, ...parsed.data, status: 'draft', createdAt: ts, updatedAt: ts };
db.set(id, article);
res.status(201).setHeader('Location', `/v1/articles/${id}`).json(article);
});
// GET /v1/articles — list with cursor pagination and filtering
app.get('/v1/articles', (req: Request, res: Response) => {
const limit = Math.min(parseInt((req.query.limit as string) ?? '20', 10), 100);
const cursor = req.query.cursor as string | undefined;
const authorId = req.query.author_id as string | undefined;
const status = req.query.status as string | undefined;
let items = Array.from(db.values());
if (authorId) items = items.filter(a => a.authorId === authorId);
if (status) items = items.filter(a => a.status === status);
let start = 0;
if (cursor) {
const idx = items.findIndex(a => a.id === cursor);
if (idx !== -1) start = idx + 1;
}
const page = items.slice(start, start + limit);
const nextCursor = page.length === limit ? page[page.length - 1].id : null;
res.json({ data: page, pagination: { limit, next_cursor: nextCursor } });
});
// GET /v1/articles/:id
app.get('/v1/articles/:id', (req: Request, res: Response) => {
const article = db.get(req.params.id);
if (!article) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }
res.json(article);
});
// PATCH /v1/articles/:id — partial update
app.patch('/v1/articles/:id', (req: Request, res: Response) => {
const article = db.get(req.params.id);
if (!article) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }
const parsed = ArticleUpdateSchema.safeParse(req.body);
if (!parsed.success) { problem(res, 422, 'Validation Error', parsed.error.issues.map(i => i.message).join(', ')); return; }
if (parsed.data.title !== undefined) article.title = parsed.data.title;
if (parsed.data.content !== undefined) article.content = parsed.data.content;
article.updatedAt = nowIso();
res.json(article);
});
// POST /v1/articles/:id/publish — action as sub-resource
app.post('/v1/articles/:id/publish', (req: Request, res: Response) => {
const article = db.get(req.params.id);
if (!article) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }
if (article.status === 'published') { problem(res, 409, 'Already Published', `Article '${req.params.id}' is already published.`, req.path); return; }
article.status = 'published';
article.updatedAt = nowIso();
res.json(article);
});
// DELETE /v1/articles/:id — 204 No Content
app.delete('/v1/articles/:id', (req: Request, res: Response) => {
if (!db.has(req.params.id)) { problem(res, 404, 'Article Not Found', `No article with id '${req.params.id}'.`, req.path); return; }
db.delete(req.params.id);
res.status(204).send();
});
app.listen(3000, () => console.log('API running on http://localhost:3000'));
Run it:
npm install
npm run dev
Test the full cycle:
# Create
curl -s -X POST http://localhost:3000/v1/articles \
-H "Content-Type: application/json" \
-d '{"title":"Hello","content":"World","authorId":"usr_1"}' | jq
# List with filter
curl -s "http://localhost:3000/v1/articles?author_id=usr_1&limit=5" | jq
# Publish (action as sub-resource)
curl -s -X POST http://localhost:3000/v1/articles/{id}/publish | jq
# Delete — expect 204
curl -s -o /dev/null -w "%{http_code}" -X DELETE http://localhost:3000/v1/articles/{id}
Full source with tests: GitHub link
Conclusion
REST API design is a set of decisions you make once and live with for years. The ones I see cause the most pain aren't exotic — they're the basics: verbs in URLs, inconsistent error shapes, offset pagination on growing datasets, no versioning plan. None of this is new. RFC 7807 has existed since 2016. Cursor pagination predates it by decades.
The reason these mistakes keep appearing in fresh codebases is that nothing stops you from making them. Frameworks don't enforce it. Tutorials skip it. You have to choose these defaults deliberately.
Now you have a reason to.
Further Reading
- RFC 7231 — HTTP/1.1 Semantics and Content — authoritative definition of HTTP methods and status codes
- RFC 7807 — Problem Details for HTTP APIs — structured error response standard
- Google API Design Guide — resource-oriented design at scale, from a team running production APIs for millions of developers
If this helped, a like and a follow are appreciated — and if you've solved this differently, drop a comment, I'd like to hear it.
Bry Writes Code — cloud and API infrastructure specialist. Building or auditing a REST API? Get in touch.





Top comments (0)