API Design for Frontend Developers: REST, Pagination, and Contracts

Learn the core principles of REST API design — resources, pagination, filtering, sorting, versioning, error formats, validation, authentication, idempotency, rate limiting, caching, and API contracts — well enough to design a clean API and to consume one correctly and defensively from React.

As a React developer you rarely design the backend, but you constantly consume its API — and understanding good API design makes you dramatically better at building resilient data layers, and far more effective when you're the one proposing an endpoint shape in a design review. This lesson covers the vocabulary and patterns that show up in almost every serious REST API, illustrated with a single running example: a blog platform's /posts API.

REST and Resources

REST (Representational State Transfer) is an architectural style built around 'resources' — nouns like posts, users, or comments — each identified by a URL, manipulated through standard HTTP methods, and represented in a consistent format (almost always JSON in modern APIs). The core idea: URLs name things, HTTP methods say what to do to them.

A resource collection lives at a plural path (/posts), and a single item within it lives at that path plus an identifier (/posts/42). Nested resources express containment: /posts/42/comments is 'the comments that belong to post 42'.

• GET /posts — list the collection (read-only, safe to cache, safe to call repeatedly). • GET /posts/42 — read a single resource. • POST /posts — create a new resource in the collection; returns 201 with the new resource, typically including its Location header. • PUT /posts/42 — replace the entire resource with the given representation. • PATCH /posts/42 — partially update the resource (only the fields provided change). • DELETE /posts/42 — remove the resource; typically returns 204.

Pagination — Never Return Unbounded Lists

A /posts endpoint that returns every post ever written does not scale — at 5 rows it's fine, at 5 million rows it will time out, exhaust memory, and send megabytes of JSON the client doesn't need. Pagination splits a large collection into manageable pages, and there are two dominant approaches.

• Offset/limit pagination — GET /posts?offset=40&limit=20 returns items 41–60. Simple to implement and to jump to arbitrary pages (great for 'page 5 of 12' UI), but can produce duplicate or skipped items if rows are inserted/deleted between page requests, and gets slow on very large offsets in naive database implementations. • Cursor-based pagination — GET /posts?after=cursor_abc123&limit=20 returns the next 20 items after an opaque cursor (often an encoded id + timestamp). Stable under concurrent writes and efficient at any depth, but you can't jump directly to 'page 5' — only 'next' and 'previous' from a known cursor. This is what infinite-scroll feeds (social media, chat apps) almost always use. • Either way, the response should include pagination metadata: total count (if offset-based), whether there's a next page, and the cursor/offset to request it — never make the client guess.

Filtering, Sorting, and Versioning

Real collections need to be narrowed and ordered, and APIs need to change over time without breaking existing clients. These three concerns are usually expressed through query parameters and the URL path.

• Filtering — query parameters narrow the collection: GET /posts?status=published&authorId=7 returns only published posts by author 7. Good APIs document exactly which fields are filterable and what operators are supported (exact match, range, in lists). • Sorting — a sort parameter controls order, often with a - prefix for descending: GET /posts?sort=-createdAt returns newest first; sort=title returns alphabetical. Some APIs support multi-field sort: sort=-createdAt,title. • Versioning — as an API evolves, breaking changes need a way to coexist with old clients. Common strategies: URL versioning (/v1/posts vs /v2/posts — simple, visible, but can duplicate routes), header versioning (Accept: application/vnd.myapi.v2+json — cleaner URLs but less discoverable), and additive-only evolution (never remove/rename fields, only add new optional ones, so old clients keep working forever). Most public APIs (Stripe, GitHub) use a mix: a version identifier plus a strong 'never break existing fields' discipline.

Error Formats and Validation

A good API returns errors in a consistent, machine-parseable shape — not a random mix of plain strings, HTML error pages, and JSON, depending on which code path failed. A predictable error envelope lets frontend code write one generic error handler instead of special-casing every endpoint.

Validation errors (typically 422) deserve special structure because the frontend needs to map them onto specific form fields, not just show one generic message.

• A typical error envelope: { "error": { "code": "VALIDATION_ERROR", "message": "...", "fields": { "email": "must be a valid email" } } } — a stable code the frontend can switch on, a human-readable message for logs/fallback display, and optional fields for form-level errors. • Validation should happen both client-side (for instant feedback — check the email looks like an email before even submitting) and server-side (the source of truth — the client-side check can be bypassed, so the server must re-validate everything regardless of what the client already checked).

Authentication

Authentication answers 'who is making this request?'. Modern REST APIs almost universally use token-based auth rather than cookies-plus-sessions for cross-origin SPA + API setups (though httpOnly cookies remain common and are often more secure against XSS).

• Bearer tokens (JWTs or opaque tokens) — the client sends Authorization: Bearer <token> on every request. Tokens are usually short-lived (minutes to hours) paired with a longer-lived refresh token, so a stolen access token has a limited blast radius. • API keys — a static, long-lived secret typically used for server-to-server or third-party integrations, not for browser-based apps (a key embedded in frontend JS is visible to anyone who opens dev tools). • OAuth 2.0 — the standard flow for 'log in with Google/GitHub' and for delegated access, ending in the app holding an access token (and sometimes a refresh token) it uses exactly like a bearer token above.

Idempotency, Rate Limiting, Caching, and API Contracts

These four concepts round out what separates a hobby API from a production-grade one, and each has a direct, visible effect on frontend behavior.

• Idempotency — an operation is idempotent if calling it N times has the same effect as calling it once. GET, PUT, and DELETE are supposed to be idempotent by definition (deleting an already-deleted resource should not error catastrophically; PUT-ing the same representation twice yields the same state). POST is NOT inherently idempotent — submitting a payment form twice (e.g. from a double-click or a retried network request) can charge a customer twice unless the API supports an idempotency key (a client-generated unique id sent in a header, which the server uses to detect and safely ignore duplicate submissions). • Rate limiting — APIs cap how many requests a client can make in a time window, to protect availability for everyone. Limits are usually communicated via response headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset) so well-behaved clients can self-throttle before hitting 429. • Caching — HTTP caching headers (Cache-Control, ETag, Last-Modified) let clients and intermediate proxies avoid re-fetching unchanged data, working hand-in-hand with the 304 Not Modified status covered in the HTTP status codes lesson. On the frontend, data-fetching libraries (React Query, SWR) add an additional application-level cache on top of HTTP caching, controlling staleness and refetch behavior independently. • API contracts — a formal, often machine-readable description of an API's shape (endpoints, parameters, request/response schemas) — most commonly expressed as an OpenAPI/Swagger specification. A solid contract lets frontend and backend teams work in parallel against an agreed shape, enables auto-generated TypeScript types and API clients (removing an entire class of 'the frontend assumed the wrong field name' bugs), and gives you a single source of truth for API documentation.

A Typed REST API Client Demonstrating Pagination, Filtering, Sorting, and Versioning

ListPostsParams mirrors exactly what the API documents as filterable/sortable — the type system prevents the frontend from requesting an unsupported filter. The v1 in API_BASE is URL-based versioning; migrating to v2 later means adding a new base and letting old code keep hitting v1 until it's migrated, rather than a breaking flag day. The Idempotency-Key header on createPost is the standard pattern (used by Stripe and many payment/critical-write APIs) for making a non-idempotent POST safe to retry.

// Shape of a single Post resource, matching the API's documented contract.
interface Post {
  id: string;
  title: string;
  authorId: string;
  status: "draft" | "published";
  createdAt: string; // ISO 8601
}

// A generic "paginated list" envelope the API uses for every collection endpoint.
interface Page<T> {
  data: T[];
  pagination: {
    nextCursor: string | null;
    hasMore: boolean;
  };
}

interface ListPostsParams {
  status?: Post["status"];
  authorId?: string;
  sort?: "createdAt" | "-createdAt" | "title" | "-title";
  after?: string; // cursor from a previous page
  limit?: number;
}

const API_BASE = "/api/v1"; // URL-based versioning: "v1" is explicit and stable

async function listPosts(params: ListPostsParams = {}): Promise<Page<Post>> {
  const query = new URLSearchParams();
  if (params.status) query.set("status", params.status);
  if (params.authorId) query.set("authorId", params.authorId);
  if (params.sort) query.set("sort", params.sort);
  if (params.after) query.set("after", params.after);
  query.set("limit", String(params.limit ?? 20));

  return apiFetch<Page<Post>>(`${API_BASE}/posts?${query.toString()}`);
}

// POST with a client-generated idempote
…

Code Examples

useInfiniteQuery maps directly onto cursor-based pagination: getNextPageParam reads pagination.nextCursor from the last fetched page and feeds it back into queryFn as pageParam for the next request. Each page is cached individually and data.pages.flatMap(...) flattens them into one continuous list for rendering — this is the standard shape for feeds, comment threads, and search results.

getNextPageParam: (lastPage) => lastPage.pagination.hasMore ? lastPage.pagination.nextCursor ?? undefined : undefined,

Instead of waiting to be told 'no' with a 429, this client reads the rate-limit headers the server proactively sends on every response and self-throttles before hitting the limit — this is the well-behaved-client pattern that keeps shared APIs healthy for everyone using them, not just your own app. Not every API exposes rate-limit headers, but major ones (GitHub, Stripe, Twitter/X) do, specifically so clients can behave this way.

Because the API guarantees every error response follows the same { error: { code, message, fields? } } shape, the frontend can write ONE generic parser and switch on a small, documented set of code values, instead of pattern-matching on inconsistent human-readable strings that break the moment someone edits a message on the backend.

import { useInfiniteQuery } from "@tanstack/react-query";

function PostFeed() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ["posts", { status: "published", sort: "-createdAt" }],
    queryFn: ({ pageParam }) =>
      listPosts({ status: "published", sort: "-createdAt", after: pageParam, limit: 20 }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (lastPage) =>
      lastPage.pagination.hasMore ? lastPage.pagination.nextCursor ?? undefined : undefined,
  });

  const posts = data?.pages.flatMap((page) => page.data) ?? [];

  return (
    <div>
      {posts.map((post) => (
        <article key={post.id}>{post.title}</article>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? "Loading…" : "Load more"}
        </button>
      )}
    </div>
  );
}
async function apiFetchWithRateLimitAwareness<T>(input: RequestInfo, init?: RequestInit): Promise<T> {
  const response = await fetch(input, init);
  const remaining = Number(response.headers.get("X-RateLimit-Remaining") ?? "Infinity");
  const resetAt = response.headers.get("X-RateLimit-Reset"); // unix timestamp, seconds

  if (remaining <= 1 && resetAt) {
    const waitMs = Math.max(0, Number(resetAt) * 1000 - Date.now());
    console.warn(`Rate limit nearly exhausted, next request will wait ${waitMs}ms`);
    // A production version would queue subsequent calls until waitMs elapses,
    // rather than letting them fire and get a guaranteed 429.
  }

  // ... continue with the same status-branching logic as apiFetch
  return response.json() as Promise<T>;
}
interface ApiErrorEnvelope {
  error: {
    code: string;
    message: string;
    fields?: Record<string, string>;
  };
}

function isApiErrorEnvelope(body: unknown): body is ApiErrorEnvelope {
  return (
    typeof body === "object" &&
    body !== null &&
    "error" in body &&
    typeof (body as any).error?.code === "string"
  );
}

async function parseErrorResponse(response: Response): Promise<string> {
  try {
    const body: unknown = await response.json();
    if (isApiErrorEnvelope(body)) {
      switch (body.error.code) {
        case "VALIDATION_ERROR":
          return "Please check the highlighted fields.";
        case "DUPLICATE_RESOURCE":
          return "That already exists.";
        default:
          return body.error.message;
      }
    }
  } catch {
    // Non-JSON error body (e.g. an HTML 502 page from a proxy).
  }
  return `Request failed (${response.status})`;
}

Cursor Pagination Across Requests

Each response hands the client the exact cursor to request the next page with — no guessing offsets.

Request 1: GET /posts?limit=20
  -> { data: [post1..post20], pagination: { nextCursor: "c20", hasMore: true } }

Request 2: GET /posts?after=c20&limit=20
  -> { data: [post21..post40], pagination: { nextCursor: "c40", hasMore: true } }

Request 3: GET /posts?after=c40&limit=20
  -> { data: [post41..post47], pagination: { nextCursor: null, hasMore: false } }
                                                              ^
                                              client stops requesting more

Important Rules

Design URLs around nouns (resources), not verbs — POST /posts not POST /createPost.

Never return an unbounded list; always paginate, and always tell the client explicitly how to get the next page.

Prefer cursor-based pagination for feeds that change frequently or scroll infinitely; offset/limit is fine for stable, jump-to-page UIs like admin tables.

Version breaking changes explicitly (URL or header); never silently change the meaning or type of an existing field.

Validate on the server regardless of client-side validation — client checks are for UX, server checks are for correctness and security.

Give non-idempotent writes (POST) an idempotency-key mechanism if they can plausibly be retried (payments, form submissions behind a flaky network).

Treat rate-limit and cache headers as data your client should read and act on, not just metadata to ignore.

Why API Contracts Matter Beyond Documentation

A mid-size company building a React admin dashboard against a Node backend hit a recurring class of bug: the frontend expected post.createdAt but the backend, after a refactor, renamed it to post.created_at in one release, breaking every date display across the app with no compile-time warning (both are just strings to plain JavaScript). After adopting an OpenAPI specification as the source of truth — with a build step that generated TypeScript types directly from it — the same mistake became a compile error the moment the backend renamed a field, caught in CI before it ever reached production. Treating the API contract as a first-class, versioned artifact (not just a wiki page that drifts out of date) turned an entire category of runtime bugs into build-time errors.

GET /getAllPostsForUser?userId=7 GET /users/7/posts REST URLs name resources and their relationships; verbs belong in the HTTP method, not the path. /users/7/posts reads as 'the posts belonging to user 7' and composes predictably with the rest of the API's conventions.

// Frontend skips validation because "the server checks it anyway" <input name="email" />

<input name="email" type="email" required /> // plus a server-side check that re-validates everything

Client-side validation exists for instant user feedback (don't make someone wait for a round-trip to learn they forgot the @ in their email); it is not a security boundary. Skipping server-side validation because the client already checked is a common vulnerability, since API calls can be made directly, bypassing the UI entirely.

// Retrying a failed POST /orders on every network error retryOnFailure(() => fetch('/orders', { method: 'POST', body }));

retryOnFailure(() => fetch('/orders', { method: 'POST', headers: { 'Idempotency-Key': key }, body }));

Without an idempotency key, retrying a POST after a timeout or dropped connection can create the resource twice (e.g. double-charging a customer) if the original request actually succeeded server-side but the client never saw the response.

[multiple-choice] Which pagination style is generally best for an infinite-scroll social feed with frequent new posts? A. Offset/limit, because it's simpler to implement B. Cursor-based, because it stays stable even as new items are inserted between requests C. No pagination — return all posts and let the client slice them D. Random sampling of posts per request

B. Cursor-based, because it stays stable even as new items are inserted between requests — Cursor-based pagination anchors each page to a specific point in the data (an opaque cursor) rather than a numeric position, so it doesn't skip or duplicate items when new posts are inserted between page requests — exactly the scenario a fast-moving feed creates constantly.

[multiple-choice] Why does a payment-creating POST endpoint typically support an Idempotency-Key header? A. To speed up the request B. To let the client skip authentication C. So a retried request (e.g. after a network timeout) doesn't create the resource / charge the customer twice D. To enable pagination on the response

C. So a retried request (e.g. after a network timeout) doesn't create the resource / charge the customer twice — POST is not inherently idempotent. If a client retries a POST after not receiving a response (even though the server actually processed it), an idempotency key lets the server recognize the duplicate and return the original result instead of performing the action again.

[multiple-choice] What is the primary purpose of an API contract like an OpenAPI specification? A. To make requests run faster B. To provide a formal, often machine-readable description of the API's shape that both frontend and backend can rely on, and that can generate types/clients C. To replace the need for authentication D. To automatically paginate all endpoints

B. To provide a formal, often machine-readable description of the API's shape that both frontend and backend can rely on, and that can generate types/clients — An API contract formally documents endpoints, parameters, and request/response schemas. Because it's machine-readable, it can drive code generation (TypeScript types, client SDKs) and let frontend/backend teams work in parallel against an agreed, versioned shape rather than informal, drifting documentation.

[true-false] True or false: skipping server-side validation is acceptable as long as the frontend form has client-side validation. A. True B. False

B. False — False. Client-side validation is only a UX convenience — anyone can call the API directly (via curl, dev tools, or a script), bypassing the frontend entirely. Server-side validation is the actual security and correctness boundary and must always be present regardless of what the client checks.

Design the endpoint shape for 'search posts by keyword, filter by author and status, sort by relevance or date, and paginate the results' — write out the exact URL with query parameters for a concrete example call, the JSON response envelope shape (including pagination metadata), and the error envelope shape you'd return if the q (query) parameter is missing.

Example: GET /v1/posts/search?q=react+hooks&authorId=7&status=published&sort=-relevance&limit=20. Response: { data: Post[], pagination: { nextCursor: string|null, hasMore: boolean } }. A missing q is a malformed request (the endpoint's entire purpose is a keyword search), so it should be 400 Bad Request with { error: { code: "MISSING_PARAMETER", message: "q is required" } } — 422 would be more appropriate for a q that's present but fails a semantic rule, like exceeding a max length.

Think about which parameters are required vs. optional, what a sensible default limit and sort would be, and whether a missing required parameter is a 400 or a 422.