API Design as a Frontend Concern
Frontend engineers consume APIs. They do not usually design them. That division of responsibility sounds clean in theory, but it produces real problems in practice: endpoints that return the wrong shape, pagination schemes that make infinite scroll awkward to implement, error responses that carry no actionable information, and versioning strategies that force breaking changes into the frontend on someone else's timeline.
The engineers closest to the UI have the clearest picture of what the API needs to deliver. Understanding API design, its trade-offs, its common patterns, and the contracts it implies, is a core competency for senior frontend engineers and leads. You do not need to own the backend to have an informed opinion about the API surface your application depends on.
What This Guide Covers
- REST and GraphQL: how each works, what problems each solves, and the architectural decision of when to choose one over the other.
- BFF architecture: aggregation, transformation, and API shaping at the server layer closest to your frontend.
- Contracts: OpenAPI, tRPC, and codegen strategies that keep TypeScript types in sync with your API surface automatically.
- Versioning, pagination, caching: the operational patterns that determine how your API evolves and how your frontend consumes data efficiently.
- Optimistic updates and error handling: the frontend patterns that make APIs feel fast and failures feel recoverable.
- Real-time patterns: when to use WebSockets, Server-Sent Events, or polling, and how to integrate them with TanStack Query.
- API security: CORS, rate limiting, input validation, and what frontend engineers need to know about securing the API layer.
REST API Principles for Frontend Engineers
REST (Representational State Transfer) structures APIs around resources accessed via standard HTTP methods. A well-designed REST API is predictable: if you know the resource and the verb, you can infer the endpoint, the expected status code, and the response shape. That predictability is what makes REST easy to consume and easy to cache.
Resource Naming and HTTP Verbs
| Method | Path | Action | Success Status | Idempotent |
|---|---|---|---|---|
| GET | /posts | List posts | 200 | Yes |
| GET | /posts/:id | Get single post | 200 | Yes |
| POST | /posts | Create post | 201 | No |
| PUT | /posts/:id | Replace post | 200 | Yes |
| PATCH | /posts/:id | Update post fields | 200 | Should be |
| DELETE | /posts/:id | Delete post | 204 | Yes |
// Core resource types mirror your API response shapes exactly
export interface Post {
id: string;
title: string;
body: string;
authorId: string;
createdAt: string; // ISO 8601 — keep dates as strings for JSON transport
updatedAt: string;
tags: string[];
}
// Consistent envelope for collection endpoints
export interface CollectionResponse<T> {
data: T[];
meta: {
total: number;
page: number;
perPage: number;
nextCursor?: string;
};
}
// Consistent envelope for single-resource endpoints
export interface ResourceResponse<T> {
data: T;
}
// Typed error response (discussed in section 11)
export interface ApiErrorResponse {
error: {
code: string;
message: string;
details?: Record<string, string[]>;
requestId: string;
};
}import type { ApiErrorResponse } from "@/types/api";
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public requestId: string,
public details?: Record<string, string[]>
) {
super(message);
this.name = "ApiError";
}
}
async function request<T>(
path: string,
init?: RequestInit
): Promise<T> {
const res = await fetch(`/api${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...init?.headers,
},
});
if (!res.ok) {
const err: ApiErrorResponse = await res.json().catch(() => ({
error: { code: "UNKNOWN", message: res.statusText, requestId: "" },
}));
throw new ApiError(
res.status,
err.error.code,
err.error.message,
err.error.requestId,
err.error.details
);
}
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string, init?: RequestInit) =>
request<T>(path, { ...init, method: "GET" }),
post: <T>(path: string, body: unknown, init?: RequestInit) =>
request<T>(path, { ...init, method: "POST", body: JSON.stringify(body) }),
patch: <T>(path: string, body: unknown, init?: RequestInit) =>
request<T>(path, { ...init, method: "PATCH", body: JSON.stringify(body) }),
del: <T>(path: string, init?: RequestInit) =>
request<T>(path, { ...init, method: "DELETE" }),
};import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type { Post, CollectionResponse } from "@/types/api";
export function usePosts(filters?: { authorId?: string; tag?: string }) {
return useQuery({
queryKey: ["posts", filters],
queryFn: () =>
api.get<CollectionResponse<Post>>("/posts", {
headers: filters
? { "X-Filters": JSON.stringify(filters) }
: undefined,
}),
staleTime: 60_000, // data is fresh for 1 minute
});
}
export function usePost(id: string) {
return useQuery({
queryKey: ["posts", id],
queryFn: () => api.get<{ data: Post }>(`/posts/${id}`),
enabled: !!id,
});
}GraphQL for Frontend Engineers
GraphQL inverts the API model: instead of the server defining fixed endpoints that return fixed shapes, clients declare exactly what data they need in a query. The server returns exactly that, no more, no less. For frontend engineers, this means no over-fetching (no unused fields), no under-fetching (no N+1 requests for related data), and a type system that is introspectable at build time.
Fragment Colocation: Matching Data Needs to Components
The most powerful GraphQL pattern for frontend engineers is fragment colocation: each component defines the data fragment it needs, and parent components compose those fragments into queries. This means a component's data dependencies live alongside its rendering logic, not spread across a centralised query file.
fragment PostCardFragment on Post {
id
title
createdAt
author {
id
name
avatarUrl
}
tags {
id
name
}
}
# The list query composes the fragment
query PostsList($cursor: String, $limit: Int = 20) {
posts(after: $cursor, first: $limit) {
edges {
node {
...PostCardFragment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}import { FragmentType, useFragment } from "@/gql";
import { PostCardFragmentDoc } from "@/gql/graphql";
interface Props {
post: FragmentType<typeof PostCardFragmentDoc>;
}
// Component is only coupled to its own fragment, not the full query type
export function PostCard({ post }: Props) {
const data = useFragment(PostCardFragmentDoc, post);
return (
<article>
<h2>{data.title}</h2>
<p>by {data.author.name}</p>
<time>{new Date(data.createdAt).toLocaleDateString()}</time>
<ul>
{data.tags.map((tag) => (
<li key={tag.id}>{tag.name}</li>
))}
</ul>
</article>
);
}import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: process.env.GRAPHQL_SCHEMA_URL ?? "http://localhost:4000/graphql",
documents: ["src/**/*.{tsx,ts,graphql}"],
generates: {
"./src/gql/": {
preset: "client",
config: {
// Fragment masking ensures components only access their declared fields
fragmentMasking: { unmaskFunctionName: "useFragment" },
// Generates TypedDocumentNode for fully typed operations
documentMode: "string",
strictScalars: true,
scalars: {
DateTime: "string",
UUID: "string",
JSON: "Record<string, unknown>",
},
},
},
},
hooks: {
afterAllFileWrite: ["prettier --write"],
},
};
export default config;graphql-codegen --watch (or yarn graphql-codegen --watch) regenerates types on every schema or document change. Wire this into your dev script alongside next dev using concurrently so TypeScript always reflects the current schema without a manual step.REST vs GraphQL: Architecture Decision
REST and GraphQL are architectural styles with different trade-offs, not a newer vs older competition. The right choice depends on your team structure, API complexity, and how much control you have over the backend. Many production systems use both.
| Dimension | REST | GraphQL |
|---|---|---|
| Data fetching | Fixed shapes per endpoint | Client declares exact fields needed |
| Over-fetching | Common without careful design | Eliminated by design |
| Under-fetching | Requires multiple requests | Single request for nested data |
| HTTP caching | Native (GET + Cache-Control) | Non-trivial (POST mutations, persisted queries help) |
| Type safety | Via OpenAPI codegen | Built into schema; codegen is simpler |
| Versioning | Requires explicit strategy | Evolve schema with deprecation |
| Learning curve | Low | Medium (schema, resolvers, codegen) |
| N+1 problem | Managed per endpoint | Requires DataLoader on server |
| File uploads | Native multipart | Non-standard, extra tooling |
| Tooling ecosystem | Mature and universal | Strong but narrower |
Choose REST When:
- Your data model is simple and resource-oriented
- HTTP caching is important (CDN, browser cache)
- Multiple clients with very different tech stacks consume the API
- Your team is small and does not need the overhead of a schema layer
- You are building a public API consumed by third parties
Choose GraphQL When:
- Your UI is highly compositional with deep data relationships
- Multiple frontend teams consume the same API with different data needs
- Over-fetching is causing measurable performance problems
- You want schema-driven development with built-in type safety
- Your team can afford the schema, resolver, and DataLoader investment
Backend for Frontend Architecture
A Backend for Frontend (BFF) is a server layer purpose-built for a specific frontend application. Rather than the frontend calling multiple upstream services directly, the BFF aggregates, transforms, and filters data from those services into exactly the shapes the UI needs. It is not a general-purpose API gateway: it is optimized for one client.
In a Next.js application, Route Handlers serve naturally as a BFF layer. They run server-side, can call multiple upstream services, have access to secrets, and return exactly the data the frontend needs in a single round-trip. This eliminates waterfall data fetching on the client and keeps sensitive credentials out of browser code.
What the BFF Does
- Aggregates data from multiple upstream services
- Transforms response shapes for the UI's needs
- Filters out fields the client does not need
- Holds API credentials and tokens (never exposed to browser)
- Enforces auth and permission checks before forwarding
- Applies caching at the server layer
What the BFF Is Not
- A general-purpose API gateway used by all clients
- A place for core business logic
- A long-term persistence layer
- Shared between web app and mobile app
- A replacement for proper upstream API design
import { auth } from "@/lib/auth";
import { NextResponse } from "next/server";
// This single endpoint fetches from three upstream services
// and returns exactly what the dashboard view needs
export async function GET() {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const userId = session.user.id;
const token = session.user.accessToken;
// Parallel fetch from multiple services
const [userProfile, recentActivity, notifications] = await Promise.all([
fetch(`${process.env.USER_SERVICE}/users/${userId}`, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: 300 }, // Cache for 5 min at edge
}).then((r) => r.json()),
fetch(`${process.env.ACTIVITY_SERVICE}/activity?userId=${userId}&limit=10`, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: 30 },
}).then((r) => r.json()),
fetch(`${process.env.NOTIFICATION_SERVICE}/notifications?userId=${userId}&unread=true`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store", // Always fresh
}).then((r) => r.json()),
]);
// Transform into exactly the shape the dashboard component needs
return NextResponse.json({
user: {
name: userProfile.displayName,
avatarUrl: userProfile.profileImage,
plan: userProfile.subscriptionTier,
},
activity: recentActivity.items.map((a: { id: string; type: string; timestamp: string; summary: string }) => ({
id: a.id,
type: a.type,
at: a.timestamp,
summary: a.summary,
})),
unreadCount: notifications.total,
});
}Data Transformation in the BFF
One of the most valuable uses of a BFF is normalizing inconsistent upstream response shapes. Different services evolve independently and often use different naming conventions, date formats, or ID schemes. The BFF absorbs that inconsistency and presents a clean, consistent interface to the frontend.
// Upstream services use different date representations and naming conventions
interface UpstreamUserV1 {
user_id: string;
display_name: string;
profile_img_url: string;
created_ts: number; // Unix timestamp
}
interface UpstreamUserV2 {
id: string;
name: { display: string };
avatar: string;
createdAt: string; // ISO 8601
}
// BFF normalises both into a single consistent frontend type
export interface FrontendUser {
id: string;
displayName: string;
avatarUrl: string;
createdAt: string; // ISO 8601 always
}
export function normaliseUserV1(u: UpstreamUserV1): FrontendUser {
return {
id: u.user_id,
displayName: u.display_name,
avatarUrl: u.profile_img_url,
createdAt: new Date(u.created_ts * 1000).toISOString(),
};
}
export function normaliseUserV2(u: UpstreamUserV2): FrontendUser {
return {
id: u.id,
displayName: u.name.display,
avatarUrl: u.avatar,
createdAt: u.createdAt,
};
}Frontend/Backend Contracts
A contract is a shared definition of what the API sends and what the client expects. Without an explicit contract, teams rely on documentation that drifts, copy types manually and let them diverge, and discover breaking changes at runtime. Three strategies for maintaining contracts in TypeScript codebases in 2026: OpenAPI codegen, tRPC, and Zod-based shared schemas.
OpenAPI Codegen
For REST APIs, an OpenAPI specification is the contract. The openapi-typescript library generates TypeScript types directly from a spec file or live schema URL, producing zero-overhead types with no runtime cost.
# Install
yarn add -D openapi-typescript
# npm install --save-dev openapi-typescript
# Generate types from a local spec file
yarn openapi-typescript ./openapi.yaml -o src/types/api.d.ts
# Or from a live schema URL (useful in CI)
yarn openapi-typescript https://api.example.com/openapi.json -o src/types/api.d.ts
# Add to package.json scripts for team use
# "codegen:api": "openapi-typescript ./openapi.yaml -o src/types/api.d.ts"import type { paths } from "@/types/api"; // auto-generated
// Extract the response type for GET /posts
type PostsListResponse =
paths["/posts"]["get"]["responses"]["200"]["content"]["application/json"];
// Extract the request body type for POST /posts
type CreatePostBody =
paths["/posts"]["post"]["requestBody"]["content"]["application/json"];
// The fetch wrapper uses generated types — no manual type maintenance
export async function listPosts(
params: paths["/posts"]["get"]["parameters"]["query"]
): Promise<PostsListResponse> {
const search = new URLSearchParams(params as Record<string, string>);
const res = await fetch(`/api/posts?${search}`);
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}
export async function createPost(body: CreatePostBody) {
const res = await fetch("/api/posts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`${res.status}`);
return res.json();
}tRPC: End-to-End Type Safety Without Codegen
tRPC provides type-safe remote procedure calls for full-stack TypeScript projects. The router is the contract: TypeScript infers the input and output types from the server definition, and the client gets those types automatically with no codegen step. If the server changes a procedure's output, the TypeScript compiler catches every consumer that breaks.
import { z } from "zod";
import { router, protectedProcedure, publicProcedure } from "../trpc";
import { db } from "@/lib/db";
export const postsRouter = router({
list: publicProcedure
.input(
z.object({
cursor: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
authorId: z.string().optional(),
})
)
.query(async ({ input }) => {
const posts = await db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
where: input.authorId ? { authorId: input.authorId } : undefined,
orderBy: { createdAt: "desc" },
});
const hasNextPage = posts.length > input.limit;
const items = hasNextPage ? posts.slice(0, -1) : posts;
return {
items,
nextCursor: hasNextPage ? items[items.length - 1].id : null,
};
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(50_000),
tags: z.array(z.string()).max(10).default([]),
})
)
.mutation(async ({ input, ctx }) => {
return db.post.create({
data: { ...input, authorId: ctx.session.user.id },
});
}),
});import { trpc } from "@/lib/trpc-client";
export function PostsList() {
// Fully typed: hover over posts.data to see inferred return type
const posts = trpc.posts.list.useQuery({ limit: 20 });
const createPost = trpc.posts.create.useMutation({
onSuccess: () => {
// Invalidate list query after creation
trpc.posts.list.invalidate();
},
});
if (posts.isLoading) return <Spinner />;
if (posts.error) return <ErrorMessage error={posts.error} />;
return (
<ul>
{posts.data?.items.map((post) => (
// post.id, post.title etc are fully typed from the server return
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}API Versioning Strategies
APIs change. Schema fields are renamed, endpoints are restructured, behaviours are modified. Versioning is the mechanism that decouples when the backend changes from when the frontend is ready to adopt those changes. Without it, every breaking backend change is a coordinated deployment across teams. With it, migrations happen asynchronously on each team's own timeline.
| Strategy | Example | Pros | Cons | Best For |
|---|---|---|---|---|
| URL versioning | /api/v2/posts | Visible, cacheable, easy routing | URL pollution, hard to share links | Public APIs, breaking changes |
| Header versioning | API-Version: 2024-11-01 | Clean URLs, date-based is explicit | Less visible, not cacheable by CDN by default | Internal APIs, Stripe-style versioning |
| Query param | /posts?version=2 | Easy to test in browser | Easy to forget, cache pollution | Rarely recommended |
| Content negotiation | Accept: application/vnd.api+json;version=2 | Semantically correct, clean URLs | Complex, rarely supported in tooling | Academic, not common in practice |
Date-Based Versioning (Stripe Pattern)
Stripe popularized the pattern of using calendar dates as version identifiers. Each API client is pinned to the date their integration was built. The backend supports multiple date versions simultaneously, and clients opt in to new versions deliberately. This is more expressive than integer versions because the date communicates when the contract changed.
const API_VERSION = "2026-01-01"; // Pin to the version your code was written against
export async function versionedFetch(path: string, init?: RequestInit) {
return fetch(`/api${path}`, {
...init,
headers: {
"API-Version": API_VERSION,
"Content-Type": "application/json",
...init?.headers,
},
});
}
// When upgrading to a new API version:
// 1. Change API_VERSION
// 2. Run type codegen to get the new types
// 3. Fix every TypeScript error the type change surfaces
// 4. Deploy
// This is migration-driven development: the compiler tells you what changed.Sunset Headers: Knowing When to Migrate
The Sunset HTTP response header tells clients when a deprecated API version or endpoint will be removed. Frontend engineers should log or surface this header in development so migrations are caught before the deadline.
// Development-only middleware: surface API deprecation warnings
export function withDeprecationMonitor(fetchFn: typeof fetch): typeof fetch {
if (process.env.NODE_ENV !== "development") return fetchFn;
return async (input, init) => {
const res = await fetchFn(input, init);
const sunset = res.headers.get("Sunset");
const deprecation = res.headers.get("Deprecation");
if (sunset) {
const url = typeof input === "string" ? input : input.url;
const sunsetDate = new Date(sunset);
const daysRemaining = Math.ceil(
(sunsetDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
);
console.warn(
`[API Deprecation] ${url} is deprecated. ` +
`Sunset: ${sunset} (${daysRemaining} days remaining).` +
(deprecation ? ` Deprecation notice: ${deprecation}` : "")
);
}
return res;
};
}Pagination Strategies
Pagination is not a minor API detail: it determines how your infinite scroll works, whether data is stable when items are inserted, and how efficiently the backend can serve large result sets. The choice between offset and cursor pagination has significant consequences for both the API and the frontend implementation.
| Strategy | URL Pattern | Pros | Cons | Best For |
|---|---|---|---|---|
| Offset / page | ?page=2&perPage=20 | Simple, jumpable (go to page 5) | Unstable with insertions, expensive COUNT | Admin tables, search results |
| Cursor | ?after=abc123&limit=20 | Stable, efficient, real-time safe | No random page access, cursor must be stable | Feeds, infinite scroll, live data |
| Keyset | ?afterId=123&afterDate=2026-01-01 | Efficient index scan, stable | Complex for multi-column sort | High-volume feeds with compound sort |
| Seek | ?seek=eyJpZCI6MTIzfQ== | Opaque to client, backend can optimise freely | Not bookmarkable | Internal APIs, mobile infinite scroll |
Cursor Pagination with TanStack Query
import { useInfiniteQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import type { CollectionResponse, Post } from "@/types/api";
export function useInfinitePosts(filters?: { tag?: string }) {
return useInfiniteQuery({
queryKey: ["posts", "infinite", filters],
queryFn: ({ pageParam }) =>
api.get<CollectionResponse<Post>>(
`/posts?limit=20${pageParam ? `&after=${pageParam}` : ""}${
filters?.tag ? `&tag=${filters.tag}` : ""
}`
),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.meta.nextCursor ?? undefined,
staleTime: 30_000,
});
}
// Component usage
export function PostFeed() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfinitePosts();
// Flatten pages into a single list
const posts = data?.pages.flatMap((page) => page.data) ?? [];
return (
<>
<ul>
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</ul>
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? "Loading..." : "Load more"}
</button>
)}
</>
);
}Virtualisation for Large Lists
When a list can grow to thousands of items, rendering all DOM nodes degrades performance. TanStack Virtual renders only the items currently visible in the viewport, keeping the DOM shallow regardless of total item count.
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useEffect } from "react";
import { useInfinitePosts } from "@/hooks/useInfinitePosts";
export function VirtualPostList() {
const parentRef = useRef<HTMLDivElement>(null);
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfinitePosts();
const posts = data?.pages.flatMap((p) => p.data) ?? [];
const virtualizer = useVirtualizer({
count: hasNextPage ? posts.length + 1 : posts.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 120,
overscan: 5,
});
// Fetch next page when the sentinel item enters the viewport
useEffect(() => {
const lastItem = virtualizer.getVirtualItems().at(-1);
if (!lastItem) return;
if (lastItem.index >= posts.length - 1 && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [virtualizer.getVirtualItems(), hasNextPage, isFetchingNextPage]);
return (
<div ref={parentRef} style={{ height: "80vh", overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((item) => (
<div
key={item.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${item.start}px)`,
}}
>
{posts[item.index] ? (
<PostCard post={posts[item.index]} />
) : (
<PostSkeleton />
)}
</div>
))}
</div>
</div>
);
}Caching Contracts
Caching is a contract between the API and the consumer: the API declares how long a response is valid, and the consumer respects that declaration. Breaking this contract in either direction causes problems: an API that declares no-cache for data that rarely changes forces unnecessary round-trips; a frontend that ignores cache headers serves stale data.
HTTP Cache Headers
| Header | Value | Meaning | Best For |
|---|---|---|---|
| Cache-Control | no-store | Never cache, always fetch fresh | User-specific data, auth responses |
| Cache-Control | private, max-age=300 | Browser can cache for 5 min, CDN cannot | Personalised content |
| Cache-Control | public, max-age=3600, s-maxage=86400 | Browser: 1hr, CDN: 24hr | Public content, product pages |
| Cache-Control | stale-while-revalidate=60 | Serve stale for 60s while fetching fresh | Near-real-time data with fast UI |
| ETag | “abc123” | Content fingerprint for conditional requests | Large resources, efficient revalidation |
| Vary | Accept, Accept-Encoding | Cache separately per header value | Content negotiation, compressed responses |
TanStack Query Cache Configuration
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
// How long data is considered fresh (no network request)
staleTime: 60_000, // 1 minute default
// How long inactive data stays in memory before garbage collection
gcTime: 5 * 60_000, // 5 minutes default
// Retry config
retry: (failureCount, error) => {
// Do not retry on auth errors or client errors
if (error instanceof ApiError && error.status < 500) return false;
return failureCount < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000), // Exponential backoff
},
},
});
// Override per-query with different stale times for different data types:
// User profile: staleTime: 5 * 60_000 (rarely changes)
// Notifications: staleTime: 0 (always re-fetch on focus)
// Product catalog: staleTime: 10 * 60_000 (changes infrequently)
// Order status: staleTime: 30_000, refetchInterval: 30_000 (poll for changes)Cache Invalidation Patterns
import { queryClient } from "@/lib/query-client";
// Query key factory ensures consistent keys across the app
export const postKeys = {
all: ["posts"] as const,
lists: () => [...postKeys.all, "list"] as const,
list: (filters: Record<string, unknown>) => [...postKeys.lists(), filters] as const,
details: () => [...postKeys.all, "detail"] as const,
detail: (id: string) => [...postKeys.details(), id] as const,
};
// After creating a post: invalidate all list queries
export function invalidatePostLists() {
return queryClient.invalidateQueries({ queryKey: postKeys.lists() });
}
// After updating a specific post: update cache directly and invalidate lists
export function updatePostInCache(updatedPost: Post) {
// Update the individual post cache entry immediately
queryClient.setQueryData(postKeys.detail(updatedPost.id), updatedPost);
// Invalidate list queries so they re-fetch with the updated item
return queryClient.invalidateQueries({ queryKey: postKeys.lists() });
}
// After deleting a post: remove from cache and invalidate lists
export function removePostFromCache(postId: string) {
queryClient.removeQueries({ queryKey: postKeys.detail(postId) });
return queryClient.invalidateQueries({ queryKey: postKeys.lists() });
}Optimistic Updates
An optimistic update applies the expected result of a mutation to the local cache before the server confirms it. The UI responds instantly. If the server succeeds, the cache is refreshed with authoritative data. If the server fails, the cache is rolled back to the snapshot taken before the mutation. Done well, optimistic updates make an application feel significantly faster than it is.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
import { postKeys } from "@/lib/cache-invalidation";
import type { Post, CollectionResponse } from "@/types/api";
interface NewPost {
title: string;
body: string;
tags: string[];
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (newPost: NewPost) =>
api.post<{ data: Post }>("/posts", newPost),
onMutate: async (newPost) => {
// Cancel any in-flight fetches that would overwrite the optimistic update
await queryClient.cancelQueries({ queryKey: postKeys.lists() });
// Snapshot the current cache value so we can roll back on error
const snapshot = queryClient.getQueryData<CollectionResponse<Post>>(postKeys.lists());
// Optimistically add the new post to the top of the list
queryClient.setQueryData<CollectionResponse<Post>>(
postKeys.list({}),
(old) => {
if (!old) return old;
const optimisticPost: Post = {
...newPost,
id: `optimistic-${Date.now()}`,
authorId: "current-user",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
return { ...old, data: [optimisticPost, ...old.data] };
}
);
// Return the snapshot so onError can use it
return { snapshot };
},
onError: (_err, _newPost, context) => {
// Roll back to the snapshot on failure
if (context?.snapshot) {
queryClient.setQueryData(postKeys.list({}), context.snapshot);
}
},
onSettled: () => {
// Always re-fetch after the mutation settles (success or error)
// to ensure the cache reflects authoritative server state
queryClient.invalidateQueries({ queryKey: postKeys.lists() });
},
});
}Optimistic Delete and Toggle
export function useToggleLike(postId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (liked: boolean) =>
api.post(`/posts/${postId}/like`, { liked }),
onMutate: async (liked) => {
await queryClient.cancelQueries({ queryKey: postKeys.detail(postId) });
const snapshot = queryClient.getQueryData<{ data: Post }>(postKeys.detail(postId));
queryClient.setQueryData<{ data: Post }>(postKeys.detail(postId), (old) => {
if (!old) return old;
return {
data: {
...old.data,
likedByUser: liked,
likeCount: liked ? old.data.likeCount + 1 : old.data.likeCount - 1,
},
};
});
return { snapshot };
},
onError: (_err, _liked, context) => {
if (context?.snapshot) {
queryClient.setQueryData(postKeys.detail(postId), context.snapshot);
}
},
onSettled: () => queryClient.invalidateQueries({ queryKey: postKeys.detail(postId) }),
});
}Error Handling Architecture
Most API error handling in frontend codebases falls into one of two failure modes: everything shows a generic “Something went wrong” message regardless of what happened, or individual components catch their own errors with inconsistent UX. A systematic error handling architecture classifies errors, handles them at the right layer, and gives users information they can act on.
Error Taxonomy
export type ApiErrorCode =
| "VALIDATION_ERROR" // 400: request body failed validation
| "UNAUTHORIZED" // 401: not authenticated
| "FORBIDDEN" // 403: authenticated but not permitted
| "NOT_FOUND" // 404: resource does not exist
| "CONFLICT" // 409: state conflict (duplicate, version mismatch)
| "RATE_LIMITED" // 429: too many requests
| "UNPROCESSABLE" // 422: valid JSON but semantically invalid
| "SERVER_ERROR" // 500: unexpected server failure
| "SERVICE_UNAVAILABLE" // 503: downstream dependency failure
| "NETWORK_ERROR" // no response: offline, DNS failure, timeout
| "UNKNOWN";
// Standard error envelope every API endpoint returns on failure
export interface ApiErrorBody {
error: {
code: ApiErrorCode;
message: string;
// Field-level validation errors
details?: Record<string, string[]>;
// Trace ID for correlating with server logs
requestId: string;
// For rate limiting: when the client can retry
retryAfter?: number;
};
}Error Handling by Layer
Global Layer (React Error Boundary)
Catches unexpected errors that escape component error handling. Shows a full-page fallback. Logs to Sentry or equivalent. Handles SERVER_ERROR and NETWORK_ERROR when no local handler is present.
Query Layer (TanStack Query onError)
Handles UNAUTHORIZED globally (redirect to login). Handles retries with exponential backoff for server errors. Toast notifications for transient errors.
Form Layer (mutation onError)
Handles VALIDATION_ERROR by mapping field errors to form fields. Handles CONFLICT with specific messaging. Never shows raw server error messages to users.
Component Layer (error prop)
Handles NOT_FOUND with empty state. Handles FORBIDDEN with a permission message rather than an error. Context- aware: a 404 on a post detail page shows “Post not found” not an error screen.
import { Component } from "react";
import type { ApiError } from "@/lib/api-client";
interface State {
error: ApiError | Error | null;
}
export class ApiErrorBoundary extends Component<
{ children: React.ReactNode; fallback?: React.ReactNode },
State
> {
state: State = { error: null };
static getDerivedStateFromError(error: unknown): State {
return { error: error instanceof Error ? error : new Error(String(error)) };
}
componentDidCatch(error: unknown, info: React.ErrorInfo) {
// Log to error tracking
console.error("[ErrorBoundary]", error, info.componentStack);
}
render() {
if (this.state.error) {
return this.props.fallback ?? <GlobalErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
function GlobalErrorFallback({ error }: { error: Error }) {
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>We encountered an unexpected error. Please refresh the page.</p>
{process.env.NODE_ENV === "development" && (
<pre>{error.message}</pre>
)}
</div>
);
}API Security for Frontend Engineers
Frontend engineers are not responsible for implementing backend security, but they are responsible for not undermining it. Several common frontend patterns introduce API security vulnerabilities or bypass controls that the backend has carefully implemented.
CORS: Understanding the Browser Contract
Cross-Origin Resource Sharing (CORS) is enforced by browsers, not servers. When your frontend makes a cross-origin request, the browser sends a preflight OPTIONS request to ask the server if the actual request is permitted. Misconfigured CORS is a common source of both developer frustration (requests blocked) and security incidents (Access-Control-Allow-Origin: * on an authenticated endpoint).
import { NextRequest, NextResponse } from "next/server";
const ALLOWED_ORIGINS = [
"https://app.example.com",
"https://staging.example.com",
...(process.env.NODE_ENV === "development" ? ["http://localhost:3000"] : []),
];
export function corsHeaders(req: NextRequest): Headers {
const origin = req.headers.get("origin") ?? "";
const headers = new Headers();
if (ALLOWED_ORIGINS.includes(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Vary", "Origin"); // Tell caches this varies by origin
}
headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization, API-Version");
headers.set("Access-Control-Max-Age", "86400"); // Cache preflight for 24h
// Do NOT set credentials: true unless you specifically need cross-origin cookies
// headers.set("Access-Control-Allow-Credentials", "true");
return headers;
}
export async function OPTIONS(req: NextRequest) {
return new NextResponse(null, { status: 204, headers: corsHeaders(req) });
}Input Validation with Zod
Client-side validation is UX: it gives users fast feedback without a round-trip. Server-side validation is security: it is the only validation that actually enforces constraints. Both are necessary. Sharing Zod schemas between client and server eliminates duplication and ensures they stay in sync.
import { z } from "zod";
export const CreatePostSchema = z.object({
title: z
.string()
.min(1, "Title is required")
.max(200, "Title must be 200 characters or fewer")
.trim(),
body: z
.string()
.min(10, "Body must be at least 10 characters")
.max(50_000, "Body must be 50,000 characters or fewer"),
tags: z
.array(z.string().max(30).trim())
.max(10, "Maximum 10 tags")
.default([]),
});
export type CreatePostInput = z.infer<typeof CreatePostSchema>;
// Server-side: validate incoming request body
// const result = CreatePostSchema.safeParse(await req.json());
// if (!result.success) return validationError(result.error);
// Client-side with react-hook-form:
// const form = useForm({ resolver: zodResolver(CreatePostSchema) });SSRF in BFF Endpoints
Server-Side Request Forgery (SSRF) is a vulnerability where an attacker tricks a server into making requests to internal infrastructure. BFF endpoints that construct upstream URLs from user input are a common SSRF vector.
// WRONG: user input controls the upstream URL
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const userId = searchParams.get("userId");
// SSRF: attacker can set userId=169.254.169.254/latest/meta-data
const data = await fetch(`${process.env.USER_SERVICE}/users/${userId}`);
return Response.json(await data.json());
}
// CORRECT: validate and allowlist before constructing URLs
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const userId = searchParams.get("userId");
// Validate that userId is a UUID before using in URL
const uuidSchema = z.string().uuid();
const result = uuidSchema.safeParse(userId);
if (!result.success) {
return Response.json({ error: { code: "VALIDATION_ERROR", message: "Invalid userId" } }, { status: 400 });
}
const data = await fetch(`${process.env.USER_SERVICE}/users/${result.data}`);
return Response.json(await data.json());
}Real-Time Patterns
Not all data needs to be real-time, and reaching for WebSockets for every live update is over-engineering. The right pattern depends on directionality (one-way vs bidirectional), latency requirements, and infrastructure cost.
| Pattern | Direction | Latency | Infrastructure | Best For |
|---|---|---|---|---|
| Polling | Client pulls | Interval-bound | Standard HTTP | Low-frequency updates, simple setup |
| Long polling | Client pulls, server holds | Near-instant | Server thread per connection | Low-volume notifications |
| Server-Sent Events (SSE) | Server pushes | Near-instant | Standard HTTP, auto-reconnect | Notifications, feeds, streaming AI responses |
| WebSocket | Bidirectional | Near-instant | Stateful, hard to scale | Chat, collaboration, live cursors, gaming |
Server-Sent Events with TanStack Query
import { useEffect } from "react";
import { useQueryClient } from "@tanstack/react-query";
export function useSSENotifications() {
const queryClient = useQueryClient();
useEffect(() => {
const es = new EventSource("/api/events/notifications", {
withCredentials: true,
});
// Update the notifications query cache in real time
es.addEventListener("notification", (event) => {
const notification = JSON.parse(event.data);
queryClient.setQueryData<Notification[]>(
["notifications", "unread"],
(old) => (old ? [notification, ...old] : [notification])
);
// Increment unread count
queryClient.setQueryData<{ count: number }>(["notifications", "count"], (old) =>
old ? { count: old.count + 1 } : { count: 1 }
);
});
// Invalidate entire notifications list on bulk events
es.addEventListener("notifications:refresh", () => {
queryClient.invalidateQueries({ queryKey: ["notifications"] });
});
es.onerror = () => {
// EventSource auto-reconnects with exponential backoff
// No manual reconnect logic needed for SSE
};
return () => es.close();
}, [queryClient]);
}WebSocket with Reconnection
import { useEffect, useRef, useCallback } from "react";
interface UseWebSocketOptions {
onMessage: (data: unknown) => void;
onConnect?: () => void;
onDisconnect?: () => void;
}
export function useWebSocket(url: string, options: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const retryCount = useRef(0);
const maxRetries = 5;
const connect = useCallback(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => {
retryCount.current = 0;
options.onConnect?.();
};
ws.onmessage = (event) => {
try {
options.onMessage(JSON.parse(event.data));
} catch {
options.onMessage(event.data);
}
};
ws.onclose = () => {
options.onDisconnect?.();
if (retryCount.current < maxRetries) {
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const delay = Math.min(1000 * 2 ** retryCount.current, 16_000);
retryCount.current++;
setTimeout(connect, delay);
}
};
}, [url]);
useEffect(() => {
connect();
return () => {
wsRef.current?.close();
};
}, [connect]);
const send = useCallback((data: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(data));
}
}, []);
return { send };
}Polling with TanStack Query
import { useQuery } from "@tanstack/react-query";
import { api } from "@/lib/api-client";
type OrderStatus = "pending" | "processing" | "shipped" | "delivered" | "failed";
export function useOrderStatus(orderId: string) {
return useQuery({
queryKey: ["orders", orderId, "status"],
queryFn: () => api.get<{ status: OrderStatus }>(`/orders/${orderId}/status`),
// Poll every 10 seconds
refetchInterval: (query) => {
const status = query.state.data?.status;
// Stop polling when the order reaches a terminal state
if (status === "delivered" || status === "failed") return false;
return 10_000;
},
// Also poll when the tab regains focus
refetchOnWindowFocus: true,
});
}API Anti-Patterns for Frontend Engineers
These patterns are common in production codebases and consistently cause performance problems, maintenance overhead, or outright bugs.
Waterfall Data Fetching
Fetching data sequentially when requests could be made in parallel is one of the most impactful performance anti-patterns in React applications. Every sequential fetch adds its full round-trip time to the total load time.
// WRONG: sequential fetches (total time = sum of all latencies)
function Dashboard() {
const user = useUser(); // 200ms
const posts = usePosts(user.data?.id); // waits for user, then 150ms
const activity = useActivity(user.data?.id); // waits for user, then 100ms
// Total: ~450ms
}
// CORRECT: parallel fetches with prefetching
// Option 1: start all queries simultaneously
function Dashboard({ userId }: { userId: string }) {
// All three start at the same time
const user = useUser(userId);
const posts = usePosts(userId); // userId known from props/route
const activity = useActivity(userId); // userId known from props/route
// Total: ~200ms (slowest of the three)
}
// Option 2: prefetch in a Server Component (Next.js)
// export default async function DashboardPage({ params }) {
// const queryClient = new QueryClient();
// await Promise.all([
// queryClient.prefetchQuery({ queryKey: ['user', params.id], queryFn: ... }),
// queryClient.prefetchQuery({ queryKey: ['posts', params.id], queryFn: ... }),
// queryClient.prefetchQuery({ queryKey: ['activity', params.id], queryFn: ... }),
// ]);
// return <HydrationBoundary state={dehydrate(queryClient)}>
// <DashboardClient />
// </HydrationBoundary>;
// }Recreating queryClient on Every Render
// WRONG: new QueryClient on every render loses all cache
function App() {
const queryClient = new QueryClient(); // new instance every render
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
// CORRECT: stable client instance
const queryClient = new QueryClient({ /* config */ });
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
</QueryClientProvider>
);
}
// Or with useState for SSR-safe instantiation:
// const [queryClient] = useState(() => new QueryClient({ ... }))Exposing Internal Data Models to the Frontend
Returning database row objects directly as API responses couples your frontend to your database schema. Renaming a column, changing a data type, or splitting a table becomes a breaking API change. BFF transformation or dedicated response types insulate the frontend from internal model changes.
Using GET for State-Changing Operations
GET requests are idempotent by definition. Caches, proxies, prefetch crawlers, and link pre-loaders all assume GET is safe to call without side effects. Using GET for deletions, state transitions, or any operation with side effects creates unpredictable behaviour and makes cache invalidation impossible.
API Patterns Without Next.js
Vite SPAs and other client-only React apps call APIs directly from the browser, without a server layer available for aggregation, transformation, or credential management. The patterns here adapt the principles in this guide to that architecture.
| Pattern | Next.js Approach | Vite SPA Equivalent |
|---|---|---|
| BFF aggregation | Route Handlers (app/api) | Express/Fastify BFF, or direct parallel fetches |
| API credentials | Server env vars, never sent to client | VITE_ prefix for public keys; secrets need BFF |
| CORS in dev | Same origin (Next.js serves API + frontend) | Vite dev proxy (vite.config.ts server.proxy) |
| Type safety | tRPC or OpenAPI codegen | OpenAPI codegen; tRPC works with any Node server |
| SSR data prefetch | Server Components, prefetchQuery | TanStack Router loaders + prefetchQuery |
Vite Dev Proxy for CORS-Free Development
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
// All /api requests in dev are proxied to the real backend
// The browser sees same-origin requests: no CORS preflight
"/api": {
target: "https://api.staging.example.com",
changeOrigin: true,
secure: true,
// Rewrite the path if the API does not use /api prefix
// rewrite: (path) => path.replace(/^/api/, ""),
},
// Proxy WebSocket connections
"/ws": {
target: "wss://api.staging.example.com",
changeOrigin: true,
ws: true,
},
},
},
});TanStack Router with Prefetched Data
import { createFileRoute } from "@tanstack/react-router";
import { queryClient } from "@/lib/query-client";
import { postKeys } from "@/lib/cache-invalidation";
import { api } from "@/lib/api-client";
import type { Post } from "@/types/api";
// Prefetch query data before the route renders (equivalent to Next.js prefetchQuery)
const postQueryOptions = (id: string) => ({
queryKey: postKeys.detail(id),
queryFn: () => api.get<{ data: Post }>(`/posts/${id}`),
staleTime: 60_000,
});
export const Route = createFileRoute("/posts/$id")({
// Runs before the component renders, can trigger prefetch
loader: ({ params }) =>
queryClient.ensureQueryData(postQueryOptions(params.id)),
component: PostDetailPage,
});
function PostDetailPage() {
const { id } = Route.useParams();
// Data is already in cache from loader, renders immediately
const { data } = useQuery(postQueryOptions(id));
return <PostDetail post={data?.data} />;
}Recommended Vite SPA API Stack for 2026
# Core data fetching
yarn add @tanstack/react-query @tanstack/react-query-devtools
# npm install @tanstack/react-query @tanstack/react-query-devtools
# Type-safe routing with loaders
yarn add @tanstack/react-router
# npm install @tanstack/react-router
# Schema validation (shared with API if you own it)
yarn add zod
# npm install zod
# OpenAPI type generation (if consuming an OpenAPI API)
yarn add -D openapi-typescript
# npm install --save-dev openapi-typescript
# For full-stack TypeScript with a Node backend
yarn add @trpc/client @trpc/react-query @trpc/server
# npm install @trpc/client @trpc/react-query @trpc/serverPromise.all or TanStack Router loaders) replaces BFF aggregation for most cases. For API keys or tokens that must not reach the browser, a lightweight Express or Fastify BFF is worth adding: it does not need to be a full Next.js application.