API Design Principles in 2026 — REST Maturity, Ergonomics, and What the Best APIs Get Right
Advertisement
Introduction
Great APIs are discoverable, predictable, and forgiving. The best ones feel obvious in hindsight — you can guess how an endpoint behaves without checking the documentation. This post covers the principles that separate great API design from mediocre ones: REST maturity, resource naming, pagination strategies with their tradeoffs, RFC 7807 error responses, and the deprecation lifecycle.
REST Maturity Model
Leonard Richardson's maturity model describes four levels of REST evolution. Most production APIs sit at Level 2. Understanding the levels helps you make conscious design decisions rather than accidental ones.
Level 0 — single endpoint, all POST, no HTTP semantics:
POST /api
Body: { "action": "get_user", "userId": "123" }Level 1 — multiple endpoints, one per resource, still mostly POST:
POST /api/users/123/delete
POST /api/users/123/activateLevel 2 — correct HTTP verbs and status codes. This is the practical target:
GET /users/123 -> 200 OK
POST /users -> 201 Created
PUT /users/123 -> 200 OK
DELETE /users/123 -> 204 No ContentLevel 3 — HATEOAS: responses include links to related resources. Rare in practice and often more complex than valuable.
Resource Naming Conventions
Consistent naming makes APIs predictable. Clients can guess endpoint paths without reading the docs.
Good: /users/123/orders, /orders/123/items
Bad: /user/123/order, /getOrders, /Orders
Good: plural nouns, lowercase, kebab-case for multi-word
Bad: mixed case, verbs, singular resource names
Collections: GET /users, /teams, /invoices
Singletons: GET /users/123, /teams/abc
Nested: GET /users/123/orders (orders belonging to user 123)
Filtered: GET /orders?user_id=123 (equivalent, often preferred for deep nesting)HTTP Method Semantics
| Method | Safe? | Idempotent? | When to use |
|---|---|---|---|
| GET | Yes | Yes | Retrieve resource, no side effects |
| POST | No | No | Create new resource |
| PUT | No | Yes | Replace entire resource |
| PATCH | No | No | Partial update |
| DELETE | No | Yes | Remove resource |
Safe means the request has no observable side effects (logging is acceptable). Idempotent means repeated identical requests produce the same result.
POST is not idempotent — retrying a POST without idempotency keys creates duplicate records. PUT is idempotent — sending the same full resource body twice results in the same state. PATCH is technically not idempotent — the result depends on the current state.
Pagination Strategies
Three pagination strategies with different tradeoffs:
Offset pagination — simple, but unstable:
// GET /users?offset=0&limit=10
// Problem: if a record is inserted between requests,
// the client will skip or see duplicates
const users = await db.users
.skip(offset)
.take(limit);Cursor pagination — stable, survives concurrent data changes:
// GET /users?limit=10&after=eyJpZCI6IjEyMyJ9
function encodeCursor(id) {
return Buffer.from(JSON.stringify({ id })).toString('base64');
}
const users = await db.users
.where('id', '>', decodedCursor.id)
.orderBy('id', 'asc')
.take(limit + 1);
const hasMore = users.length > limit;
const nextCursor = hasMore ? encodeCursor(users[limit - 1].id) : null;Keyset pagination — most efficient at scale, works with database indexes:
// GET /events?after_id=123&after_created_at=2026-03-15T10:00:00Z
// Translates directly to an indexed range scan
const events = await db.events
.where('created_at', '>', afterCreatedAt)
.orderBy('created_at', 'asc')
.take(limit);Use cursor pagination for public APIs (stable for clients). Use keyset pagination for internal APIs where you control both sides and want maximum performance.
Error Response Format — RFC 7807
RFC 7807 standardizes error responses with a Problem Details structure. Every error includes a type URL, title, status, detail, and instance.
// RFC 7807 Problem Details
const errorResponse = {
"type": "https://api.example.com/errors/validation-error",
"title": "Validation Failed",
"status": 400,
"detail": "The 'email' field must be a valid email address.",
"instance": "/users/creation",
"trace_id": "550e8400-e29b-41d4-a716-446655440000",
"invalid_fields": {
"email": "Must be a valid email address"
}
};Implementation in Express:
class ApiError extends Error {
constructor(type, title, status, detail, extra) {
super(detail);
this.type = type;
this.title = title;
this.status = status;
this.detail = detail;
this.extra = extra;
}
}
function errorHandler(err, req, res, next) {
if (err instanceof ApiError) {
return res.status(err.status).json({
type: err.type,
title: err.title,
status: err.status,
detail: err.detail,
instance: req.path,
trace_id: req.headers['x-trace-id'],
...err.extra
});
}
res.status(500).json({
type: 'https://api.example.com/errors/internal-error',
title: 'Internal Server Error',
status: 500,
detail: 'An unexpected error occurred.',
instance: req.path,
trace_id: req.headers['x-trace-id']
});
}Sensible Defaults and Progressive Disclosure
Good APIs work without configuration. Clients opt into more detail when they need it.
# Default: minimal response
GET /users/123
{ "id": "123", "name": "Alice", "email": "alice@example.com" }
# Opt-in: include related resources
GET /users/123?include=orders,profile
{ "id": "123", ..., "orders": [...], "profile": {...} }
# Opt-in: sparse fields
GET /users/123?fields=id,name
{ "id": "123", "name": "Alice" }
# Collection defaults
GET /users
# Equivalent to: /users?limit=20&sort=-created_atRate Limit Headers
Return rate limit state in every response so clients can implement intelligent backoff:
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 1000,
standardHeaders: true, // Returns RateLimit-* headers
legacyHeaders: false
});
app.use(limiter);
// Response headers after every request:
// RateLimit-Limit: 1000
// RateLimit-Remaining: 847
// RateLimit-Reset: 1645000060
// Retry-After: 60 (included on 429 responses)Deprecation and Sunset Lifecycle
Signal deprecation through response headers. Give clients 6 to 12 months to migrate before a hard shutdown.
function deprecationMiddleware(sunsetDate, successorUrl) {
return (req, res, next) => {
res.set({
'Deprecation': 'true',
'Sunset': new Date(sunsetDate).toUTCString(),
'Link': `<${successorUrl}>; rel="successor-version"`
});
next();
};
}
app.get(
'/v1/users/:id',
deprecationMiddleware('2026-09-15', 'https://api.example.com/v2/users'),
handleUserRequest
);
// Client receives:
// Deprecation: true
// Sunset: Mon, 15 Sep 2026 00:00:00 GMT
// Link: <https://api.example.com/v2/users>; rel="successor-version"Key Takeaways
- Most production APIs should target REST Level 2: correct HTTP verbs and status codes, consistent resource naming
- Use plural lowercase nouns for collection endpoints; avoid verbs in paths
- GET is safe and idempotent; POST is neither — always require idempotency keys for POST endpoints
- Cursor pagination is stable under concurrent data changes; offset pagination skips or duplicates records when data shifts between pages
- RFC 7807 Problem Details provides a standardized error structure with
type,title,status,detail, andtrace_id - Include
RateLimit-RemainingandRateLimit-Resetheaders in every response so clients implement backoff without guessing - Announce deprecations at least 6 months before shutdown using
DeprecationandSunsetheaders with a link to the replacement
Advertisement