REST API Integration Best Practices
Patterns for integrating REST APIs cleanly on the frontend — error handling, caching, and typing responses.
Almost every frontend bug I’ve debugged that wasn’t a rendering issue traces back to how the app talks to its API: an unhandled error state, a stale cache, a response shape that silently drifted from what the code assumed. REST integration looks simple — it’s “just” HTTP — which is exactly why teams skip building the structure around it and pay for it later. Here’s the structure that’s held up across several production codebases.
A Centralized API Client, Not Scattered Fetch Calls
If fetch or axios calls are written inline in components, every call site re-implements error handling, base URL resolution, and auth headers slightly differently. The fix is a single client that every feature’s data layer goes through:
interface RequestOptions {
params?: Record<string, string>;
signal?: AbortSignal;
}
class ApiClient {
constructor(private baseUrl: string, private getToken: () => string | null) {}
async get<T>(path: string, options?: RequestOptions): Promise<T> {
return this.request<T>('GET', path, undefined, options);
}
async post<T>(path: string, body: unknown, options?: RequestOptions): Promise<T> {
return this.request<T>('POST', path, body, options);
}
private async request<T>(
method: string,
path: string,
body?: unknown,
options?: RequestOptions
): Promise<T> {
const url = new URL(path, this.baseUrl);
if (options?.params) {
Object.entries(options.params).forEach(([k, v]) => url.searchParams.set(k, v));
}
const token = this.getToken();
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal: options?.signal,
});
if (!res.ok) {
const errorBody = await res.json().catch(() => null);
throw new ApiError(res.status, errorBody);
}
return res.json() as Promise<T>;
}
}
Every feature imports this one client. When the API host changes, auth refresh logic changes, or you need to add a correlation ID header to every request, it’s one file, not a grep-and-replace across the codebase. This is the same centralization principle covered in more depth in Scalable Frontend Architecture.
Typing Responses
An untyped API response is a liability that surfaces at runtime instead of compile time. At minimum, define interfaces for every response shape you consume:
interface ProductDto {
id: string;
name: string;
price_cents: number;
in_stock: boolean;
}
async function fetchProduct(id: string): Promise<ProductDto> {
return apiClient.get<ProductDto>(`/products/${id}`);
}
Where the backend publishes an OpenAPI spec, generating types from it (via openapi-typescript, NSwag, or similar) is worth the setup cost on any API with more than a handful of endpoints — it keeps the frontend’s types honest against what the backend actually returns, and a spec change that breaks the contract shows up as a type error instead of a production bug.
npx openapi-typescript https://api.example.com/openapi.json -o src/types/api.ts
Generated types describe the wire format. It’s still worth mapping that DTO shape to a cleaner internal domain type at the boundary — this is where price_cents becomes a formatted price, and where a backend’s inconsistent naming doesn’t leak into your components. See TypeScript Best Practices for more on keeping that boundary disciplined.
Consistent Error Handling
Network failures and API error responses are different problems and need to be distinguishable in code, not just logged the same way:
class ApiError extends Error {
constructor(public status: number, public body: unknown) {
super(`API error ${status}`);
}
}
class NetworkError extends Error {
constructor(cause: unknown) {
super('Network request failed');
this.cause = cause;
}
}
async function safeFetch<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (err) {
if (err instanceof ApiError) throw err; // already a structured API error
throw new NetworkError(err); // fetch threw before getting a response at all
}
}
A NetworkError (offline, DNS failure, CORS misconfiguration, timeout) usually warrants a generic “check your connection” message and a retry option. An ApiError with a 404 or 422 usually warrants something specific — “that item no longer exists,” a field-level validation message pulled from the response body. Collapsing both into one generic “something went wrong” toast is the single most common integration mistake I see, and it makes debugging user-reported issues much harder since you’ve thrown away the distinction before it ever reaches your logs.
Retry and Backoff for Transient Failures
Not every failure should be retried — a 400 or 422 will fail identically on retry. A 503, a timeout, or a connection reset often won’t. Exponential backoff with a cap and a retry limit handles the transient case without hammering a struggling server:
async function withRetry<T>(
fn: () => Promise<T>,
{ retries = 3, baseDelayMs = 300 }: { retries?: number; baseDelayMs?: number } = {}
): Promise<T> {
for (let attempt = 0; ; attempt++) {
try {
return await fn();
} catch (err) {
const retryable = err instanceof NetworkError ||
(err instanceof ApiError && [502, 503, 504].includes(err.status));
if (!retryable || attempt >= retries) throw err;
const delay = baseDelayMs * 2 ** attempt + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
The small random jitter (Math.random() * 100) matters at scale — without it, many clients that failed at the same moment retry in lockstep and create a new spike against the recovering server.
Caching Strategies
Not every request needs to hit the network every time. Two patterns cover most cases:
In-memory caching for data that’s expensive to fetch and doesn’t change often within a session (a user’s profile, a list of reference data like countries or categories):
const cache = new Map<string, { data: unknown; expiresAt: number }>();
async function cachedGet<T>(key: string, fetchFn: () => Promise<T>, ttlMs = 60_000): Promise<T> {
const entry = cache.get(key);
if (entry && entry.expiresAt > Date.now()) return entry.data as T;
const data = await fetchFn();
cache.set(key, { data, expiresAt: Date.now() + ttlMs });
return data;
}
Stale-while-revalidate for data where showing something immediately matters more than showing the absolute latest value — serve the cached value right away while a background request refreshes it for next time. Libraries like TanStack Query or SWR implement this pattern directly and handle the harder edges (request deduplication, cache invalidation on mutation, background refetch on window focus) better than a hand-rolled cache will:
import { useQuery } from '@tanstack/react-query';
function useProduct(id: string) {
return useQuery({
queryKey: ['product', id],
queryFn: () => fetchProduct(id),
staleTime: 60_000, // serve cached data for 60s before refetching in background
});
}
For anything beyond trivial in-memory caching, reach for one of these libraries rather than reimplementing cache invalidation — it’s a deceptively hard problem and the edge cases (race conditions between a mutation and an in-flight refetch, for instance) are already solved there.
Loading, Error, and Empty States — Consistently
Every data-fetching component has at minimum four states: loading, error, empty, and populated. Handling only the happy path is the most common gap between a demo and a production-ready feature.
function ProductList({ categoryId }: { categoryId: string }) {
const { data, error, isLoading } = useProducts(categoryId);
if (isLoading) return <ProductListSkeleton />;
if (error) return <ErrorState message="Couldn't load products. Try again." onRetry={refetch} />;
if (!data || data.length === 0) return <EmptyState message="No products in this category yet." />;
return (
<ul>
{data.map((p) => <ProductCard key={p.id} product={p} />)}
</ul>
);
}
Building shared <Skeleton>, <ErrorState>, and <EmptyState> components once, and using them everywhere data is fetched, keeps this from being an afterthought re-solved inconsistently in every feature.
FAQ
Should I retry POST requests automatically? Be careful — retrying a non-idempotent request (creating an order, charging a payment) can duplicate the side effect if the first request actually succeeded but the response was lost. Only auto-retry idempotent operations (GET, PUT with a full resource, DELETE) unless the API explicitly supports idempotency keys.
Is a GraphQL-style single endpoint simpler than REST for the frontend? It solves different problems — primarily over-fetching — but it doesn’t remove the need for any of the above: you still need error handling, caching, and typed responses. The patterns in this article apply with minor adjustment either way.
Do I need a library like TanStack Query for a small app? Not always — a simple in-memory cache and the patterns above can be enough for a handful of endpoints. The calculus changes once you have several screens sharing the same data and need cache invalidation to behave correctly across them.
Backend framework choice affects some of this too — see Node.js vs NestJS if you’re also deciding how the API side should be structured, since a consistent error response shape from the backend makes all of the frontend error handling above considerably simpler to write.
Related Reading
Written by
Tayyab Roy
Senior Frontend / Full-Stack Engineer
Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.
Related Articles
Building Scalable Frontend Architecture
Architectural patterns that keep a frontend codebase maintainable as it grows from a handful of components to hundreds.
TypeScript Best Practices for Large Applications
TypeScript patterns that actually hold up as a codebase grows — strict mode, discriminated unions, and avoiding type soup.
JSON to TypeScript Converter: A Practical Guide (Plus C# and Node.js)
How to convert JSON into TypeScript interfaces, C# classes, or Node.js types the right way — free online tools, real examples, and the pitfalls auto-generated types miss.