- Published on
API Design Principles in 2026 — REST Maturity, Ergonomics, and What the Best APIs Get Right
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Great APIs are discoverable, predictable, and forgiving. This post covers REST maturity levels, pagination patterns with tradeoffs, error formats (RFC 7807), HTTP semantics, and the deprecation lifecycle that separates good APIs from legacy ones.
- REST Maturity Model (Levels 0-3)
- Resource Naming Conventions
- HTTP Method Semantics
- Pagination Patterns (Offset vs Cursor vs Keyset)
- Error Response Format (RFC 7807 Problem Details)
- API Ergonomics (Sensible Defaults, Progressive Disclosure)
- Rate Limit Response Headers
- Deprecation and Sunset Lifecycle
- Checklist
- Conclusion
REST Maturity Model (Levels 0-3)
Leonard Richardson's maturity model describes REST evolution:
Level 0: Swamp of POX
- Single endpoint, all POST
- No HTTP semantics
POST /api
Body: { "action": "get_user", "userId": "123" }
Level 1: Resources
- Multiple endpoints, one per resource
- Still mostly POST
GET /api/users/123
POST /api/users
POST /api/users/123/delete
Level 2: HTTP Verbs
- Correct HTTP methods (GET, POST, PUT, DELETE)
- Correct status codes (200, 201, 404, etc.)
GET /api/users/123 → 200
POST /api/users → 201
PUT /api/users/123 → 200
DELETE /api/users/123 → 204
Level 3: HATEOAS
- Responses include links to related resources
- Client discovers API through navigation
{
"id": "123",
"name": "Alice",
"_links": {
"self": { "href": "/users/123" },
"orders": { "href": "/users/123/orders" },
"edit": { "href": "/users/123", "method": "PUT" }
}
}
Most production APIs are Level 2. Level 3 is rare (complex, adds little value).
Resource Naming Conventions
Consistent naming makes APIs predictable.
✓ Good: /users/{id}/orders, /orders/{id}/items
✗ Bad: /user/{id}/order, /getOrders, /Orders
✓ Good: plural nouns (resources), lowercase
✗ Bad: mixed case, verbs, singular
✓ Collection: GET /users, /teams, /invoices
✓ Singular: GET /users/123, /teams/abc, /invoices/xyz
✓ Nested: /users/123/orders (user's orders)
✓ Filtered: GET /orders?user_id=123 (alternative)
✓ List: GET /invoices?status=draft
✗ Bad: GET /drafts, GET /invoices/draft
HTTP Method Semantics
| Method | Semantics | Idempotent? | Safe? | Example |
|---|---|---|---|---|
| GET | Retrieve resource | Yes | Yes | GET /users/123 |
| POST | Create resource | No | No | POST /users (create new) |
| PUT | Replace entire resource | Yes | No | PUT /users/123 (full update) |
| PATCH | Partial update | No | No | PATCH /users/123 (update fields) |
| DELETE | Remove resource | Yes | No | DELETE /users/123 |
GET: Only side effects that don't modify state (logging is OK). Cacheable.
POST: Create new resource, or RPC-style action. Not idempotent; retries can create duplicates.
PUT: Replace entire resource. Idempotent (same body → same result). Include all fields.
PATCH: Update specific fields. Not idempotent (depends on order of application).
// HTTP method choice example
PUT /users/123
{
"name": "Alice",
"email": "alice@example.com",
"role": "admin" // Must include all fields
}
PATCH /users/123
{
"role": "user" // Only update this field
}
Pagination Patterns (Offset vs Cursor vs Keyset)
Offset pagination: Simple but breaks with data changes.
// Offset: skip N, take M
GET /users?offset=0&limit=10 → users 0-9
GET /users?offset=10&limit=10 → users 10-19
// Problem: If user #5 is inserted between requests:
// Request 1: returns users [0-9]
// Request 2: returns [10-19] (but user who was #10 is now #11!)
// Result: user #10 is skipped
Cursor pagination: Encode position as opaque cursor, survives data changes.
// Cursor: position encoded as base64(id, timestamp)
GET /users?limit=10 → {
data: [...],
next_cursor: "eyJpZCI6IjEyMyIsInRzIjoxMjM0NTY3ODkwfQ=="
}
GET /users?limit=10&cursor=eyJpZCI6IjEyMyIsInRzIjoxMjM0NTY3ODkwfQ== → {
data: [...],
next_cursor: "..."
}
// Pros: Consistent regardless of insertions/deletions
// Cons: Cannot jump to page N (must iterate)
Keyset pagination: Use natural sort key (ID, timestamp) as cursor.
// Keyset: "give me next 10 after id=123"
GET /users?limit=10&after_id=123 → {
data: [...],
next_after_id: "456"
}
// Most efficient at scale
// Works with database indices
Use cursor pagination for public APIs (safe from concurrent changes). Use keyset pagination for internal APIs (best performance).
// Pagination implementation
export async function paginateUsers(
limit: number = 10,
cursor?: string
): Promise<{
data: User[];
next_cursor: string | null;
}> {
const query = db.select('*').from('users').limit(limit + 1);
if (cursor) {
// Decode cursor to find starting position
const { id } = JSON.parse(Buffer.from(cursor, 'base64').toString());
query.where('id', '>', id);
}
const rows = await query.exec();
const hasMore = rows.length > limit;
const data = rows.slice(0, limit);
const nextCursor = hasMore
? Buffer.from(JSON.stringify({ id: data[data.length - 1].id })).toString('base64')
: null;
return { data, next_cursor: nextCursor };
}
Error Response Format (RFC 7807 Problem Details)
RFC 7807 standardizes error responses. Include type, title, status, detail, instance.
// RFC 7807 Problem Details
{
"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",
"invalid_fields": {
"email": "Must be valid email"
},
"trace_id": "550e8400-e29b-41d4-a716-446655440000"
}
Implement in Express:
// error-handler.ts
import express from 'express';
class ApiError extends Error {
constructor(
public type: string,
public title: string,
public status: number,
public detail: string,
public instance?: string,
public extra?: Record<string, any>
) {
super(detail);
}
}
export function errorHandler(
err: Error,
req: express.Request,
res: express.Response,
next: express.NextFunction
) {
if (err instanceof ApiError) {
return res.status(err.status).json({
type: err.type,
title: err.title,
status: err.status,
detail: err.detail,
instance: err.instance || req.path,
trace_id: req.headers['x-trace-id'],
...err.extra,
});
}
// Generic error
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'],
});
}
app.use(errorHandler);
// Usage
app.post('/users', (req, res, next) => {
if (!req.body.email) {
return next(
new ApiError(
'https://api.example.com/errors/validation-error',
'Validation Failed',
400,
"The 'email' field is required.",
'/users',
{ invalid_fields: { email: 'Required' } }
)
);
}
});
API Ergonomics (Sensible Defaults, Progressive Disclosure)
Good APIs ship with smart defaults. Clients opt into advanced features.
// 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",
"name": "Alice",
"email": "alice@example.com",
"orders": [...],
"profile": {...}
}
// Opt-in: sparse fields
GET /users/123?fields=id,name → {
"id": "123",
"name": "Alice"
}
// Defaults: same as /users?limit=20&offset=0&sort=-created_at
GET /users
Rate Limit Response Headers
Communicate rate limits via standard headers. Clients implement backoff intelligently.
HTTP/1.1 200 OK
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 999
X-RateLimit-Reset: 1645000000
Retry-After: 60
// Or newer standard (IETF draft)
RateLimit-Limit: 1000
RateLimit-Remaining: 999
RateLimit-Reset: 1645000000
Implementation:
// rate-limit-middleware.ts
import rateLimit from 'express-rate-limit';
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 1000, // requests per window
standardHeaders: true, // Return rate limit info in RateLimit-* headers
legacyHeaders: false, // Disable X-RateLimit-* headers
skip: (req) => req.user?.isPremium, // Skip for premium users
});
app.use(limiter);
// Custom headers
app.use((req, res, next) => {
res.set({
'RateLimit-Limit': '1000',
'RateLimit-Remaining': String(1000 - req.rateLimit.current),
'RateLimit-Reset': String(Math.ceil(Date.now() / 1000) + 60),
});
next();
});
Deprecation and Sunset Lifecycle
Signal deprecation via headers. Give clients 6-12 months to migrate.
HTTP/1.1 200 OK
Deprecation: true
Sunset: Mon, 15 Sep 2026 00:00:00 GMT
Link: <https://docs.example.com/v2/users>; rel="successor-version"
Warning: 299 - "This endpoint will be shut down on 2026-09-15. Use /v2/users instead."
Implementation:
// Deprecate endpoint
app.get('/v1/users/:id', deprecationMiddleware, (req, res) => {
res.set({
'Deprecation': 'true',
'Sunset': new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(),
'Link': '<https://docs.example.com/v2/users>; rel="successor-version"',
});
// Return data from v2 handler
return handleUserRequest(req, res);
});
function deprecationMiddleware(req, res, next) {
res.set({
'Deprecation': 'true',
'Sunset': 'Mon, 15 Sep 2026 00:00:00 GMT',
});
next();
}
Checklist
- Resource names: plural nouns, lowercase, consistent
- HTTP semantics: GET safe, POST creates, PUT replaces, DELETE removes
- Pagination: use cursor for public APIs, keyset for internal
- Errors: RFC 7807 format with type, title, status, detail, trace_id
- Rate limits: include RateLimit-* headers in responses
- Defaults: sensible pagination (limit=20), fields (minimal), sort
- Progressive disclosure: ?include=field, ?fields=id,name
- Deprecation: 6-12 month lifecycle, use Deprecation/Sunset headers
- Documentation: OpenAPI 3.0, with runnable examples
- Versioning: v1, v2 in path (/v2/users) or header (Accept: application/vnd.api+v2+json)
Conclusion
The best APIs are predictable and forgiving. Use HTTP semantics correctly. Paginate with cursors. Return error details in RFC 7807 format. Give clients clear migration paths. Document defaults and what can be customized. Deprecate slowly (6-12 months). These practices compound into APIs clients love using.