Skip to main content
Engineering Reference · 2026

API Design for Frontend Engineers

A production-grade handbook covering REST, GraphQL, BFF architecture, frontend/backend contracts, versioning, pagination, caching, optimistic updates, error handling, real-time patterns, and API security for modern React applications.

~55 min readIntermediate to AdvancedREST · GraphQL · BFF · TanStack · 2026
01 / Introduction

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.

The contract is the interface. Every API endpoint is a contract between two teams. The most common source of frontend slowdowns is not missing features or slow networks: it is API surfaces that were not designed with the consuming UI in mind, discovered late and renegotiated under deadline pressure. The earlier frontend engineers engage in API design, the less rework both sides do.

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.
02 / REST

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

MethodPathActionSuccess StatusIdempotent
GET/postsList posts200Yes
GET/posts/:idGet single post200Yes
POST/postsCreate post201No
PUT/posts/:idReplace post200Yes
PATCH/posts/:idUpdate post fields200Should be
DELETE/posts/:idDelete post204Yes
tstypes/api.ts: typed REST resource shapes
// 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;
  };
}
tslib/api-client.ts: typed fetch wrapper for REST
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" }),
};
tsxhooks/usePosts.ts: TanStack Query with REST
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,
  });
}
03 / GraphQL

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.

graphqlfragments/PostCard.graphql: component-colocated fragment
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
    }
  }
}
tsxcomponents/PostCard.tsx: type-safe with generated types
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>
  );
}
tscodegen.ts: GraphQL Code Generator config
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;
Run codegen in watch mode during development. The command 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.
04 / REST vs GraphQL

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.

DimensionRESTGraphQL
Data fetchingFixed shapes per endpointClient declares exact fields needed
Over-fetchingCommon without careful designEliminated by design
Under-fetchingRequires multiple requestsSingle request for nested data
HTTP cachingNative (GET + Cache-Control)Non-trivial (POST mutations, persisted queries help)
Type safetyVia OpenAPI codegenBuilt into schema; codegen is simpler
VersioningRequires explicit strategyEvolve schema with deprecation
Learning curveLowMedium (schema, resolvers, codegen)
N+1 problemManaged per endpointRequires DataLoader on server
File uploadsNative multipartNon-standard, extra tooling
Tooling ecosystemMature and universalStrong 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
tRPC as a third path: for full-stack TypeScript applications where the frontend team owns both ends, tRPC provides end-to-end type safety without a schema language, codegen, or REST conventions. It is the best option when the frontend and backend live in the same codebase and you want zero-overhead type inference across the network boundary. Covered in section 06.
05 / BFF Architecture

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
tsapp/api/dashboard/route.ts: BFF aggregating multiple services
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.

tslib/bff/transformers.ts: normalising upstream shapes
// 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,
  };
}
06 / Contracts

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.

bashGenerating TypeScript types from an OpenAPI spec
# 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"
tslib/api-typed.ts: using generated OpenAPI types
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.

tsserver/router/posts.ts: tRPC router definition
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 },
      });
    }),
});
tsxcomponents/PostsList.tsx: tRPC client usage
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>
  );
}
07 / Versioning

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.

StrategyExampleProsConsBest For
URL versioning/api/v2/postsVisible, cacheable, easy routingURL pollution, hard to share linksPublic APIs, breaking changes
Header versioningAPI-Version: 2024-11-01Clean URLs, date-based is explicitLess visible, not cacheable by CDN by defaultInternal APIs, Stripe-style versioning
Query param/posts?version=2Easy to test in browserEasy to forget, cache pollutionRarely recommended
Content negotiationAccept: application/vnd.api+json;version=2Semantically correct, clean URLsComplex, rarely supported in toolingAcademic, 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.

tslib/api-versioned.ts: version header injection
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.

tslib/deprecation-monitor.ts: checking Sunset headers in dev
// 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;
  };
}
08 / Pagination

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.

StrategyURL PatternProsConsBest For
Offset / page?page=2&perPage=20Simple, jumpable (go to page 5)Unstable with insertions, expensive COUNTAdmin tables, search results
Cursor?after=abc123&limit=20Stable, efficient, real-time safeNo random page access, cursor must be stableFeeds, infinite scroll, live data
Keyset?afterId=123&afterDate=2026-01-01Efficient index scan, stableComplex for multi-column sortHigh-volume feeds with compound sort
Seek?seek=eyJpZCI6MTIzfQ==Opaque to client, backend can optimise freelyNot bookmarkableInternal APIs, mobile infinite scroll

Cursor Pagination with TanStack Query

tsxhooks/useInfinitePosts.ts: cursor-based infinite scroll
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.

tsxcomponents/VirtualPostList.tsx: TanStack Virtual + infinite query
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>
  );
}
09 / Caching

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

HeaderValueMeaningBest For
Cache-Controlno-storeNever cache, always fetch freshUser-specific data, auth responses
Cache-Controlprivate, max-age=300Browser can cache for 5 min, CDN cannotPersonalised content
Cache-Controlpublic, max-age=3600, s-maxage=86400Browser: 1hr, CDN: 24hrPublic content, product pages
Cache-Controlstale-while-revalidate=60Serve stale for 60s while fetching freshNear-real-time data with fast UI
ETag“abc123”Content fingerprint for conditional requestsLarge resources, efficient revalidation
VaryAccept, Accept-EncodingCache separately per header valueContent negotiation, compressed responses

TanStack Query Cache Configuration

tslib/query-client.ts: cache configuration by data type
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

tslib/cache-invalidation.ts: structured query key invalidation
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() });
}
10 / Optimistic Updates

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.

tsxhooks/useCreatePost.ts: optimistic creation with rollback
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

tsxhooks/useToggleLike.ts: optimistic toggle with rollback
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) }),
  });
}
11 / Error Handling

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

tstypes/errors.ts: error classification
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.

tsxcomponents/ApiErrorBoundary.tsx: global error boundary
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>
  );
}
12 / Security

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).

tsapp/api/route-with-cors.ts: Next.js CORS configuration
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.

tslib/schemas/post.ts: shared Zod schema for client and server
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.

tsanti-pattern vs correct: BFF URL construction
// 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());
}
13 / Real-Time

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.

PatternDirectionLatencyInfrastructureBest For
PollingClient pullsInterval-boundStandard HTTPLow-frequency updates, simple setup
Long pollingClient pulls, server holdsNear-instantServer thread per connectionLow-volume notifications
Server-Sent Events (SSE)Server pushesNear-instantStandard HTTP, auto-reconnectNotifications, feeds, streaming AI responses
WebSocketBidirectionalNear-instantStateful, hard to scaleChat, collaboration, live cursors, gaming

Server-Sent Events with TanStack Query

tsxhooks/useSSENotifications.ts: SSE integrated with query cache
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

tsxhooks/useWebSocket.ts: robust WebSocket with reconnect
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

tshooks/useOrderStatus.ts: smart polling that stops when done
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,
  });
}
14 / Anti-Patterns

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.

tsxanti-pattern vs correct: sequential vs parallel fetching
// 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

tsxanti-pattern vs correct: queryClient instantiation
// 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.

15 / Without Next.js

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.

PatternNext.js ApproachVite SPA Equivalent
BFF aggregationRoute Handlers (app/api)Express/Fastify BFF, or direct parallel fetches
API credentialsServer env vars, never sent to clientVITE_ prefix for public keys; secrets need BFF
CORS in devSame origin (Next.js serves API + frontend)Vite dev proxy (vite.config.ts server.proxy)
Type safetytRPC or OpenAPI codegenOpenAPI codegen; tRPC works with any Node server
SSR data prefetchServer Components, prefetchQueryTanStack Router loaders + prefetchQuery

Vite Dev Proxy for CORS-Free Development

tsvite.config.ts: proxy API calls to avoid CORS in 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

tssrc/routes/posts.$id.tsx: TanStack Router loader with TanStack Query
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

bashSetting up the API layer in a Vite + React project
# 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/server
The key difference in a Vite SPA: there is no server layer to aggregate data or hold credentials. Parallel fetching (using Promise.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.