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
| Action | Method | Path | Returns |
|---|---|---|---|
| List | GET | /orders?status=open&page=2 | 200 + collection |
| Get one | GET | /orders/{id} | 200 + resource, 404 if missing |
| Create | POST | /orders | 201 + Location header |
| Replace | PUT | /orders/{id} | 200 / 204 |
| Partial update | PATCH | /orders/{id} | 200 / 204 |
| Delete | DELETE | /orders/{id} | 204 |
| Action on resource | POST | /orders/{id}/cancel | 200 |
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
| Code | Meaning | Frontend action |
|---|---|---|
| 200 / 201 / 204 | Success | Render |
| 301 / 302 / 307 | Redirect | Follow (fetch does automatically) |
| 304 | Not modified | Use cache |
| 400 | Bad request (client) | Show validation error |
| 401 | Unauthenticated | Try refresh; if fails, sign-in screen |
| 403 | Forbidden | Show "no access" |
| 404 | Not found | Show empty/not-found UI |
| 409 | Conflict (e.g., stale write) | Refetch, prompt user |
| 422 | Validation failed | Map field errors to form |
| 429 | Rate limited | Honor Retry-After, exponential backoff |
| 5xx | Server error | Backoff, 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.tsimport 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.tsIf 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
useInfiniteQuerybuilt 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-01Encode 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 ModifiedFrontend 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
| Symptom | Cause | Fix |
|---|---|---|
| Response shape differs in prod vs staging | No runtime parse | zod.parse at the boundary |
| Login redirects to login loop | 401 handler that signs out before refresh tries | Try refresh first, then sign out |
| Retried payment creates duplicates | Missing idempotency key | Generate key once per logical op |
| List shows stale row after edit | Mutation didn't invalidate the list cache | invalidateQueries on mutation success |
| Race: stale response overwrites newer state | Concurrent requests, no cancellation | Pass AbortSignal, dedup at TanStack Query layer |
Network request failed everywhere on iOS | App Transport Security blocking HTTP | Use HTTPS, or whitelist domains explicitly |