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

Sanjeev SharmaSanjeev Sharma
6 min read

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

Level 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 Content

Level 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

MethodSafe?Idempotent?When to use
GETYesYesRetrieve resource, no side effects
POSTNoNoCreate new resource
PUTNoYesReplace entire resource
PATCHNoNoPartial update
DELETENoYesRemove 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_at

Rate 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, and trace_id
  • Include RateLimit-Remaining and RateLimit-Reset headers in every response so clients implement backoff without guessing
  • Announce deprecations at least 6 months before shutdown using Deprecation and Sunset headers with a link to the replacement

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading