Steven's Knowledge

REST

REST API consumption from the frontend — resources, status codes, pagination, idempotency, OpenAPI codegen

REST

REST is the lingua franca of frontend↔backend communication. This page focuses on what frontend engineers need to get right, not a full REST tutorial.

Resource Modeling Cheat Sheet

ActionMethodPathReturns
ListGET/orders?status=open&page=2200 + collection
Get oneGET/orders/{id}200 + resource, 404 if missing
CreatePOST/orders201 + Location header
ReplacePUT/orders/{id}200 / 204
Partial updatePATCH/orders/{id}200 / 204
DeleteDELETE/orders/{id}204
Action on resourcePOST/orders/{id}/cancel200

Frontend code should match these semantics. Treating GET /orders/cancel/{id} as normal makes caching, retry, and CDN behavior unpredictable.

Status Codes The Frontend Actually Handles

CodeMeaningFrontend action
200 / 201 / 204SuccessRender
301 / 302 / 307RedirectFollow (fetch does automatically)
304Not modifiedUse cache
400Bad request (client)Show validation error
401UnauthenticatedTry refresh; if fails, sign-in screen
403ForbiddenShow "no access"
404Not foundShow empty/not-found UI
409Conflict (e.g., stale write)Refetch, prompt user
422Validation failedMap field errors to form
429Rate limitedHonor Retry-After, exponential backoff
5xxServer errorBackoff, retry up to N, show generic error
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
  const res = await fetch(`${BASE}${path}`, opts);

  if (res.status === 204) return undefined as T;
  if (res.status === 401) throw new UnauthorizedError();
  if (res.status === 403) throw new ForbiddenError();
  if (res.status === 422) throw new ValidationError(await res.json());
  if (res.status === 429) throw new RateLimitedError(res.headers.get('Retry-After'));
  if (!res.ok) throw new ServerError(res.status);

  return Schema.parse(await res.json());  // zod validates the shape
}

Typed Clients From OpenAPI

Hand-typing endpoints is a guaranteed source of bugs. Generate from the spec.

openapi-typescript (Types Only)

npx openapi-typescript https://api.example.com/openapi.json -o ./src/api/schema.d.ts
import type { paths } from './schema';

type GetOrdersResponse = paths['/orders']['get']['responses']['200']['content']['application/json'];

orval (Types + Hooks)

// orval.config.ts
export default {
  api: {
    input: './openapi.json',
    output: {
      target: './src/api/generated.ts',
      client: 'react-query',
      mode: 'tags-split',
      override: { mutator: { path: './src/api/client.ts', name: 'apiClient' } },
    },
  },
};

Generates fully-typed useGetOrders, useCreateOrder hooks that wire into TanStack Query.

Regeneration In CI

# part of CI workflow
- name: Check OpenAPI types up to date
  run: |
    npx openapi-typescript $OPENAPI_URL -o ./src/api/schema.d.ts
    git diff --exit-code ./src/api/schema.d.ts

If the generated file drifts, the PR fails — forcing regen.

Pagination

Three common shapes. The frontend code differs significantly per shape.

Offset / Page

GET /orders?page=2&limit=20
→ { items: [...], total: 1240, page: 2, limit: 20 }
  • Simple, but breaks on insert — same item can appear on two pages, or be skipped.
  • OK for read-only static datasets.

Cursor

GET /orders?after=eyJpZCI6...&limit=20
→ { items: [...], nextCursor: 'eyJpZCI6...' }
  • Stable under concurrent writes.
  • The frontend stores the opaque cursor and asks for the next page.
  • TanStack Query has useInfiniteQuery built for this.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
  queryKey: ['orders'],
  queryFn: ({ pageParam }) => api.getOrders({ after: pageParam }),
  initialPageParam: undefined as string | undefined,
  getNextPageParam: (last) => last.nextCursor,
});

Time-Based

GET /events?before=2024-01-15T00:00:00Z&limit=50
  • Best for chronological feeds.
  • Beware of clock skew between client and server.

Filtering And Querying

Use query parameters for filters. Do not stuff a JSON object into a q= parameter — it's hard to log, hard to cache, hard to share.

GET /orders?status=open&customer=42&createdAfter=2024-01-01

Encode arrays explicitly:

GET /orders?status=open&status=pending     # repeated
GET /orders?status=open,pending             # comma-separated (less standard)

Match the backend convention; do not pick at random.

Idempotency

Retrying a POST /payments blindly can double-charge. Backends supporting idempotency expect an Idempotency-Key header:

async function createPayment(body: PaymentBody) {
  return request<Payment>('/payments', {
    method: 'POST',
    headers: { 'Idempotency-Key': crypto.randomUUID(), 'Content-Type': 'application/json' },
    body: JSON.stringify(body),
  });
}

The key must persist across retries for the same logical operation. Generate once, retry with the same value.

Caching

HTTP-Level

GET /products/123 HTTP/1.1
Cache-Control: max-age=60, stale-while-revalidate=120
ETag: "abc123"

# Subsequent request
If-None-Match: "abc123"
→ 304 Not Modified

Frontend fetch honors these automatically. For mobile, native HTTP clients (NSURLSession, OkHttp) also honor them.

Client-Side

TanStack Query / SWR / RTK Query give an in-memory cache keyed by query key:

useQuery({
  queryKey: ['order', id],
  queryFn: () => api.getOrder(id),
  staleTime: 30_000,         // serve from cache for 30s
  gcTime: 5 * 60_000,        // keep in memory 5min after last use
});

Invalidation

const qc = useQueryClient();
const mutation = useMutation({
  mutationFn: api.updateOrder,
  onSuccess: (updated) => {
    qc.setQueryData(['order', updated.id], updated);   // update cached single
    qc.invalidateQueries({ queryKey: ['orders'] });    // refetch the list
  },
});

Network Layer Boilerplate

A thin wrapper centralizes auth, base URL, timeouts, and error normalization. Avoid scattering fetch calls throughout the codebase.

// api/client.ts
const BASE = process.env.API_BASE_URL!;
const TIMEOUT_MS = 15_000;

export async function apiClient<T>(
  path: string,
  init: RequestInit & { schema: ZodType<T> },
  signal?: AbortSignal,
): Promise<T> {
  const ac = new AbortController();
  const timeout = setTimeout(() => ac.abort(), TIMEOUT_MS);
  // Compose external signal with our timeout signal
  signal?.addEventListener('abort', () => ac.abort());

  const token = await tokenStore.get();

  try {
    const res = await fetch(`${BASE}${path}`, {
      ...init,
      signal: ac.signal,
      headers: {
        'Content-Type': 'application/json',
        ...(token ? { Authorization: `Bearer ${token}` } : {}),
        ...init.headers,
      },
    });

    if (!res.ok) throw await buildError(res);
    if (res.status === 204) return undefined as T;

    const json = await res.json();
    return init.schema.parse(json);
  } finally {
    clearTimeout(timeout);
  }
}

Multipart / File Uploads

fetch accepts FormData directly. Don't set Content-Type manually — the browser/runtime sets the boundary.

const fd = new FormData();
fd.append('file', file);
fd.append('caption', caption);

await fetch('/uploads', { method: 'POST', body: fd });

For large files, prefer pre-signed URLs to upload directly to S3/GCS, bypassing your backend.

Server-Sent Events (SSE) And Streaming

fetch + ReadableStream reads streaming responses in modern environments:

const res = await fetch('/stream');
const reader = res.body!.getReader();
const decoder = new TextDecoder();
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value, { stream: true });
  // parse SSE frames
}

For LLM token streaming or live dashboards, SSE is often simpler than WebSockets.

Common Pitfalls

SymptomCauseFix
Response shape differs in prod vs stagingNo runtime parsezod.parse at the boundary
Login redirects to login loop401 handler that signs out before refresh triesTry refresh first, then sign out
Retried payment creates duplicatesMissing idempotency keyGenerate key once per logical op
List shows stale row after editMutation didn't invalidate the list cacheinvalidateQueries on mutation success
Race: stale response overwrites newer stateConcurrent requests, no cancellationPass AbortSignal, dedup at TanStack Query layer
Network request failed everywhere on iOSApp Transport Security blocking HTTPUse HTTPS, or whitelist domains explicitly

On this page