API Design as a Frontend Concern
As a frontend engineer, you call APIs every day. But you probably didn't design them; someone on the backend did. That split sounds fine in theory, but it causes real problems: you get back data in the wrong shape, infinite scroll breaks because the pagination is awkward to work with, error responses give you nothing useful, and the backend team changes something without warning. These problems happen when the people who know the UI best are left out of API decisions.
You don't need to own the backend to have a real say in how the API works. Understanding API design, the trade-offs, the common patterns, and what each choice means for your UI, is one of the most valuable skills a senior frontend engineer can have. This guide gives you the vocabulary and the context to push for APIs that actually fit the UI you're building.
What This Guide Covers
- REST and GraphQL: how each one works, what problems each one solves, and how to decide which to use.
- BFF architecture: how to use a server layer to combine and shape data specifically for your frontend.
- Contracts: how to keep your TypeScript types in sync with the API automatically, using OpenAPI, tRPC, or code generation.
- Versioning, pagination, caching: the patterns that determine how your API evolves and how your frontend loads data efficiently.
- Optimistic updates and error handling: how to make your app feel fast and make failures feel recoverable.
- Real-time patterns: when to use WebSockets, Server-Sent Events, or polling, and how to wire them into TanStack Query.
- API security: what frontend engineers need to know about CORS, rate limiting, input validation, and common security mistakes.
REST API Principles for Frontend Engineers
REST (Representational State Transfer) is the most common API style. The idea is simple: every "thing" in your system (a user, a post, an order) is called a resource. You interact with resources using standard HTTP methods like GET, POST, PATCH, and DELETE. If you know the resource name and the HTTP method, you can usually predict the URL, the status code, and what comes back. That predictability is what makes REST easy to use and easy to cache.
Resource Naming and HTTP Verbs
“Idempotent” in the table below means: you can call the same request multiple times and get the same result. GET, PUT, and DELETE are idempotent: calling GET /poststen times doesn't change anything. POST is not; calling it ten times creates ten posts.
| 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
With REST, the server decides what data each endpoint gives you. You call /posts/123 and you get back a full post object whether you need all the fields or not. GraphQL flips this around. You tell the server exactly which fields you want, and it sends back only those, no extras, nothing missing. For frontend engineers this means no wasted data, and no need to make multiple requests just to get related data.
Fragment Colocation: Matching Data Needs to Components
The most useful GraphQL pattern for frontend work is fragment colocation. “Colocation” means keeping related things together. Instead of one big central query file that everyone has to edit, each component declares its own small data requirement called a fragment. Parent components then stitch those fragments together into a full query. This way, a component's data needs live right next to the component itself, not buried in a separate 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) re-generates types every time the schema or a query changes. Add it to your dev script alongside next dev using concurrently so your TypeScript types always reflect the current schema, no manual step needed.REST vs GraphQL: Architecture Decision
REST and GraphQL are two different ways to build APIs. Neither is better than the other; they solve different problems. The right choice depends on your team, your data, and how much control you have over the backend. Many real-world systems use both at the same time.
| Dimension | REST | GraphQL |
|---|---|---|
| Data fetching | Server decides what shape each endpoint returns | Client picks exactly which fields it needs |
| Over-fetching | Common without careful design | Not possible: you only get what you asked for |
| Under-fetching | May need multiple requests for related data | One request can fetch deeply nested data |
| HTTP caching | Built-in (GET + Cache-Control headers) | Tricky: most requests use POST; persisted queries help |
| Type safety | Needs OpenAPI + codegen | Built into the schema; codegen is simpler |
| Versioning | Needs an explicit versioning strategy | Add fields freely; deprecate old ones |
| Learning curve | Low: most engineers already know it | Medium: schema, resolvers, and codegen to learn |
| N+1 problem | Handled per endpoint | Needs DataLoader on the server to fix |
| File uploads | Works natively with multipart | Non-standard, needs extra tooling |
| Tooling ecosystem | Mature and widely supported | Strong but more niche |
Choose REST When:
- Your data is simple and resource-shaped
- Caching matters (CDN, browser cache work great with REST)
- Multiple clients with very different tech stacks use the same API
- Your team is small and doesn't need the overhead of a schema layer
- You're building a public API for third parties to use
Choose GraphQL When:
- Your UI is complex with lots of deeply nested, related data
- Multiple frontend teams need the same data but in different shapes
- You're fetching too much unused data and it's slowing things down
- You want a schema as the single source of truth for types
- Your team can invest in schema design, resolvers, and DataLoader
Backend for Frontend Architecture
A BFF (Backend for Frontend) is a server that exists specifically to serve your frontend app. Instead of your browser calling five different services and combining the data in JavaScript, the BFF does all that combining on the server, then sends back exactly what the UI needs in one clean response. It's not a general-purpose API. It's purpose-built for one client.
In a Next.js app, Route Handlers work naturally as a BFF layer. They run on the server, can call multiple upstream services, keep API keys and tokens safe (they never leave the server), and return one clean response to the frontend in a single round-trip. This removes the “waterfall” problem (where one request has to wait for another to finish) and keeps credentials out of the browser entirely.
What the BFF Does
- Combines data from multiple upstream services into one response
- Reshapes data to match exactly what the UI needs
- Strips out fields the client doesn't need
- Stores API credentials and tokens (never sent to the browser)
- Checks auth and permissions before passing requests through
- Caches responses at the server layer
What the BFF Is Not
- A shared API used by web, mobile, and other clients
- A place to put core business logic
- A database or long-term storage layer
- Shared between different apps
- A replacement for good 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 useful things a BFF does is smooth out differences between services. Each service evolves on its own timeline and may use different naming styles, date formats, or ID structures. Rather than making the frontend deal with all of that inconsistency, the BFF absorbs it and always sends the frontend a clean, predictable shape.
// 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 agreement about what the API sends and what the client expects. Without one, your TypeScript types drift away from the actual API over time. You copy types by hand, they get outdated, and you discover something is broken when a real user hits it, not when you write the code. In 2026 there are three main ways to keep this tight: OpenAPI codegen, tRPC, and Zod schemas.
OpenAPI Codegen
For REST APIs, OpenAPI is the contract. It's a standard file format that describes your entire API: every endpoint, every request shape, every response shape. The openapi-typescript tool reads that file and generates TypeScript types for you automatically. No more writing types by hand. When the API changes, re-run the command and your types update.
# 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 (TypeScript Remote Procedure Call) is the easiest way to get type safety across the network, but only when you own both sides. There's no schema file to write and no codegen command to run. You write server-side functions (called procedures), and TypeScript automatically infers the input and output types on the client. If you change a server function's return type, the TypeScript compiler immediately shows an error in every client file that calls it.
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 over time. Fields get renamed, response shapes get restructured, behaviours get modified. Without versioning, every breaking backend change has to happen at exactly the same time as a frontend update, which means two teams have to coordinate a deployment for every single change. Good versioning lets each side update on its own schedule.
| Strategy | Example | Pros | Cons | Best For |
|---|---|---|---|---|
| URL versioning | /api/v2/posts | Easy to see, easy to cache, simple routing | Clutters URLs, awkward for shared links | Public APIs, breaking changes |
| Header versioning | API-Version: 2024-11-01 | Clean URLs, date-based is explicit | Less visible, CDN doesn't cache by default | Internal APIs, Stripe-style versioning |
| Query param | /posts?version=2 | Easy to test in a browser | Easy to forget, can pollute caches | Rarely recommended |
| Content negotiation | Accept: application/vnd.api+json;version=2 | Technically correct, clean URLs | Complex, poorly supported by most tooling | Rare in practice |
Date-Based Versioning (Stripe Pattern)
Stripe made date-based versioning popular. Each API key is pinned to a date, the date the integration was built. The backend supports multiple date versions at the same time. When you're ready to upgrade your integration, you pick a newer date version and migrate deliberately. Using dates instead of “v1”, “v2” makes it clear exactly when the API 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 SunsetHTTP response header is the API's way of saying “this endpoint goes away on [date].” In development, you should log a warning any time you see this header, so the team knows a migration is coming before the deadline hits and things start breaking.
// 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 (how a list is split into pages) is not a small detail. It directly affects how your infinite scroll works, whether items jump around when new data gets inserted, and how fast the database queries run. The choice between offset-based and cursor-based pagination has real consequences for both sides.
| Strategy | URL Pattern | Pros | Cons | Best For |
|---|---|---|---|---|
| Offset / page | ?page=2&perPage=20 | Simple, can jump to any page | Items shift when new rows are inserted; expensive database COUNT | Admin tables, search results |
| Cursor | ?after=abc123&limit=20 | Stable even with new inserts, works well with live data | No random page access; cursor must stay stable | Feeds, infinite scroll, live data |
| Keyset | ?afterId=123&afterDate=2026-01-01 | Very fast database scan, stable | Gets complex with multi-column sort | High-volume feeds with compound sort |
| Seek | ?seek=eyJpZCI6MTIzfQ== | Client doesn't need to know what the cursor means | Can't bookmark or share the position | 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, putting all of them in the DOM at once makes the page slow and laggy. TanStack Virtual solves this by only rendering the items that are currently visible on screen. The rest simply don't exist in the DOM. A list with 100,000 items only ever has a few dozen DOM nodes at any given time.
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 an agreement between the API and the client: the API says “this response is valid for 5 minutes”, and the client should trust that. Break the agreement in either direction and you get problems. Cache data the API says not to cache, and users see stale data. Ignore the cache headers and re-fetch data that barely changes, and you waste server resources and slow your app down.
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 caches for 1 hour, CDN for 24 hours | Public content, product pages |
| Cache-Control | stale-while-revalidate=60 | Serve slightly stale data for up to 60s while fetching fresh | Near-real-time data where fast UI matters |
| ETag | “abc123” | A fingerprint of the content: lets the client ask “has this changed?” | Large resources, efficient re-checks |
| Vary | Accept, Accept-Encoding | Store a separate cached version for each 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 means: don't wait for the server before showing the result in the UI. When a user clicks “Like”, update the count immediately. When they submit a new post, show it in the list right away. If the server later says it failed, undo the change and show an error. Done well, optimistic updates make your app feel instant, even on a slow network.
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 apps handle errors badly. Either everything shows a useless “Something went wrong” message, or each component handles errors its own way with no consistency. A good error system classifies what went wrong, handles it at the right layer, and gives users a message they can actually act on.
Error Taxonomy
export type ApiErrorCode =
| "VALIDATION_ERROR" // 400: the request body failed validation (user input problem)
| "UNAUTHORIZED" // 401: not logged in
| "FORBIDDEN" // 403: logged in but not allowed to do this
| "NOT_FOUND" // 404: the resource doesn't exist
| "CONFLICT" // 409: something conflicts (duplicate, version mismatch)
| "RATE_LIMITED" // 429: too many requests, slow down
| "UNPROCESSABLE" // 422: valid JSON but the data makes no sense
| "SERVER_ERROR" // 500: something broke on the server
| "SERVICE_UNAVAILABLE" // 503: a service the server depends on is down
| "NETWORK_ERROR" // no response at all: 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 (e.g. { "email": ["must be valid"] })
details?: Record<string, string[]>;
// Trace ID so you can find this error in server logs
requestId: string;
// For rate limiting: how many seconds until the client can retry
retryAfter?: number;
};
}Error Handling by Layer
Global Layer (React Error Boundary)
Catches unexpected errors that slip through everything else. Shows a full-page fallback. Logs to Sentry or similar. Handles SERVER_ERROR and NETWORK_ERROR when nothing closer catches them.
Query Layer (TanStack Query onError)
Handles UNAUTHORIZED globally: redirect to login. Retries server errors with exponential backoff (wait longer after each failure). Shows a toast for transient errors.
Form Layer (mutation onError)
Handles VALIDATION_ERROR by putting error messages next to the right form fields. Handles CONFLICT with a specific message. Never shows raw server error text to users.
Component Layer (error prop)
Handles NOT_FOUND with an empty state. Handles FORBIDDENwith a permission message, not a scary error screen. The message should match the context: a 404 on a post page says “Post not found” not “Error 404”.
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
You're not building the backend security system, but you can easily break it from the frontend. A few common frontend patterns punch holes in security that the backend team worked hard to close. This section covers what frontend engineers need to know.
CORS: Understanding the Browser Contract
CORS (Cross-Origin Resource Sharing) is a browser rule that controls which websites can call which APIs. It's enforced by the browser, not the server. When your frontend on example.com tries to call an API on api.other.com, the browser first sends a “preflight” OPTIONSrequest to ask the API “is this allowed?” before sending the real request. Misconfigured CORS is a common cause of both blocked requests and security holes (setting Access-Control-Allow-Origin: * on an authenticated endpoint is a real problem).
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 for the user: it gives fast feedback without a network round-trip. Server-side validation is for security: it's the only validation that actually matters, because users can bypass client-side checks entirely. You need both. Using Zod lets you write one schema and share it between client and server so they always 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
SSRF (Server-Side Request Forgery) is a vulnerability where an attacker tricks your server into making requests to internal infrastructure, like internal databases, cloud metadata endpoints, or other services not meant to be publicly accessible. BFF endpoints that build upstream URLs from user input are a common place this happens. The fix is simple: validate the input before using it in a URL.
// 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 everything needs to be real-time. WebSockets are powerful but expensive: they require a persistent connection on the server and are hard to scale. Before reaching for WebSockets, check if polling (regularly re-fetching) or Server-Sent Events (server pushes updates to the browser) would solve the problem with less complexity.
| Pattern | Direction | Latency | Infrastructure | Best For |
|---|---|---|---|---|
| Polling | Client pulls on a timer | Limited by poll interval | Standard HTTP, nothing special needed | Low-frequency updates, simple setup |
| Long polling | Client asks, server waits to reply | Near-instant | Ties up a server thread per connection | Low-volume notifications |
| Server-Sent Events (SSE) | Server pushes to client | Near-instant | Standard HTTP, browser auto-reconnects | Notifications, feeds, streaming AI responses |
| WebSocket | Both directions at once | Near-instant | Stateful server, hard to scale horizontally | 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 are patterns you'll see in real production codebases. They all look harmless at first, but they cause real performance problems, maintenance headaches, or outright bugs over time.
Waterfall Data Fetching
Waterfall fetching is when request B waits for request A to finish before starting, even though they could run at the same time. Each sequential request adds its full round-trip time to the total load time. 200ms + 150ms + 100ms = 450ms, when running them in parallel would take just 200ms (the slowest one).
// 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
If your API returns raw database rows, the frontend is now tightly coupled to your database schema. Rename a column in the database, and you just broke the API. Change a data type, and the frontend breaks. Always use a dedicated response type and transform the data at the API or BFF layer; the frontend should never know what the database looks like.
Using GET for State-Changing Operations
GET is supposed to be “safe”: calling it shouldn't change anything. Browsers, CDNs, browser prefetch crawlers, and link pre-loaders all assume GET is read-only and call it freely. If you use GET to delete something or change state, those things can get triggered by accident, by a browser preloading links, by a cache re-validating, or by a bot crawling the page. Always use POST, PATCH, or DELETE for anything that changes data.
API Patterns Without Next.js
If you're building a Vite SPA (a client-only React app with no server), all your API calls happen directly from the browser. There's no server layer to act as a BFF. The patterns in this guide still apply; they just work a bit differently without a server in the middle.
| 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 a BFF |
| CORS in dev | Same origin (Next.js serves API + frontend together) | 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.allor TanStack Router loaders replaces BFF aggregation for most cases. When you genuinely need to hide an API key from the browser, adding a small Express or Fastify server is worth it; it doesn't need to be a full Next.js app.