Skip to content
Back to the Lab
Frontend Architecture

Structuring Your Frontend API Layer for Scale and Reliability

Learn how to build a production-grade frontend API layer with typed HTTP clients, domain modules, TanStack Query integration, retry logic, and Mock Service Worker. Used by senior engineers at scale.

Structuring Your Frontend API Layer for Scale and Reliability

Introduction#

Every frontend application that communicates with a server has an API layer. In most codebases, that layer is informal: fetch calls scattered across components, inconsistent error handling, no retry logic, and no shared abstractions. The result is an application that is fragile, hard to test, and painful to maintain as the API surface grows.

A well-designed API layer is one of the highest-leverage investments a frontend team can make. It centralizes concerns that would otherwise be duplicated everywhere: authentication headers, request serialization, error normalization, loading states, caching, and retry behavior. This guide covers the patterns and tooling that make the difference between an API layer that works and one that scales.

1. The Problem with Scattered Fetch Calls#

Consider a typical component in an application without a structured API layer:

useEffect(() => {
  setLoading(true);
  fetch("/api/users/" + userId, {
    headers: { Authorization: "Bearer " + localStorage.getItem("token") },
  })
    .then((res) => {
      if (!res.ok) throw new Error("Request failed");
      return res.json();
    })
    .then((data) => {
      setUser(data);
      setLoading(false);
    })
    .catch((err) => {
      setError(err.message);
      setLoading(false);
    });
}, [userId]);

This pattern has several problems. The authentication logic is duplicated in every component that makes an authenticated request. Error handling is inconsistent and swallows the actual HTTP status code. The loading state management is boilerplate that must be written and maintained everywhere. There is no retry logic, no request deduplication, and no caching.

Multiply this across fifty components and the codebase becomes impossible to maintain. Changing the authentication scheme requires touching every component. Adding a global error handler requires hunting through the entire codebase.

2. Building a Typed HTTP Client#

The foundation of a well-structured API layer is a typed HTTP client: a thin wrapper around fetch (or Axios) that handles cross-cutting concerns centrally and returns strongly typed responses.

// lib/http.ts
type RequestOptions = RequestInit & {
  params?: Record<string, string | number | boolean>;
};

type ApiResponse<T> = {
  data: T;
  status: number;
};

class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public code?: string
  ) {
    super(message);
    this.name = "ApiError";
  }
}

async function request<T>(
  endpoint: string,
  options: RequestOptions = {}
): Promise<ApiResponse<T>> {
  const { params, ...fetchOptions } = options;

  const url = new URL(endpoint, process.env.NEXT_PUBLIC_API_URL);
  if (params) {
    Object.entries(params).forEach(([key, value]) => {
      url.searchParams.set(key, String(value));
    });
  }

  const token = getAuthToken(); // Centralized auth token retrieval

  const response = await fetch(url.toString(), {
    ...fetchOptions,
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
      ...fetchOptions.headers,
    },
  });

  if (!response.ok) {
    const errorBody = await response.json().catch(() => ({}));
    throw new ApiError(
      errorBody.message ?? "An unexpected error occurred",
      response.status,
      errorBody.code
    );
  }

  const data = await response.json();
  return { data, status: response.status };
}

export const http = {
  get:    <T>(url: string, options?: RequestOptions) =>
            request<T>(url, { ...options, method: "GET" }),
  post:   <T>(url: string, body: unknown, options?: RequestOptions) =>
            request<T>(url, { ...options, method: "POST",  body: JSON.stringify(body) }),
  put:    <T>(url: string, body: unknown, options?: RequestOptions) =>
            request<T>(url, { ...options, method: "PUT",   body: JSON.stringify(body) }),
  patch:  <T>(url: string, body: unknown, options?: RequestOptions) =>
            request<T>(url, { ...options, method: "PATCH", body: JSON.stringify(body) }),
  delete: <T>(url: string, options?: RequestOptions) =>
            request<T>(url, { ...options, method: "DELETE" }),
};

All authentication, base URL resolution, and error normalization now live in one place. Changing the auth scheme is a one-line change.

3. Domain-Scoped API Modules#

Once you have a typed HTTP client, the next layer is organizing API calls by domain. Rather than calling http.get directly from components, each domain has its own API module that encapsulates the specific endpoints, request shapes, and response transformations for that domain.

// api/users.ts
import { http } from "@/lib/http";
import { z } from "zod";

const UserSchema = z.object({
  id:        z.string().uuid(),
  name:      z.string(),
  email:     z.string().email(),
  role:      z.enum(["admin", "editor", "viewer"]),
  createdAt: z.string().datetime(),
});

const UserListSchema = z.object({
  users: z.array(UserSchema),
  total: z.number(),
  page:  z.number(),
});

export type User     = z.infer<typeof UserSchema>;
export type UserList = z.infer<typeof UserListSchema>;

export const usersApi = {
  getById: async (id: string): Promise<User> => {
    const { data } = await http.get<unknown>(`/users/${id}`);
    return UserSchema.parse(data);
  },

  list: async (params: { page?: number; role?: string }): Promise<UserList> => {
    const { data } = await http.get<unknown>("/users", { params });
    return UserListSchema.parse(data);
  },

  update: async (id: string, payload: Partial<Pick<User, "name" | "role">>): Promise<User> => {
    const { data } = await http.patch<unknown>(`/users/${id}`, payload);
    return UserSchema.parse(data);
  },

  delete: async (id: string): Promise<void> => {
    await http.delete(`/users/${id}`);
  },
};

The Zod parsing step is critical. It validates the API response against the expected schema at runtime, which means you catch API contract violations immediately rather than discovering them as subtle bugs deep in the UI.

4. Integrating with TanStack Query#

Domain API modules and TanStack Query are a natural pair. The API module provides the data fetching function. TanStack Query provides caching, background refetching, loading states, and error states. Together they eliminate the vast majority of state management boilerplate for server data.

Query Key Factory#

Consistent query key management is essential for cache invalidation. A query key factory centralizes key definitions and makes it impossible to accidentally use the wrong key string.

// api/queryKeys.ts
export const userKeys = {
  all:     ()           => ["users"]                   as const,
  lists:   ()           => ["users", "list"]           as const,
  list:    (params: object) => ["users", "list", params] as const,
  details: ()           => ["users", "detail"]         as const,
  detail:  (id: string) => ["users", "detail", id]     as const,
};

Custom Hooks per Domain#

// hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { usersApi } from "@/api/users";
import { userKeys }  from "@/api/queryKeys";

export function useUser(id: string) {
  return useQuery({
    queryKey:  userKeys.detail(id),
    queryFn:   () => usersApi.getById(id),
    staleTime: 5 * 60 * 1000,
  });
}

export function useUserList(params: { page?: number; role?: string } = {}) {
  return useQuery({
    queryKey: userKeys.list(params),
    queryFn:  () => usersApi.list(params),
  });
}

export function useDeleteUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => usersApi.delete(id),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: userKeys.lists() });
    },
  });
}
// Component usage: clean, no fetch boilerplate
function UserList() {
  const { data, isLoading, error } = useUserList({ page: 1 });
  const { mutate: deleteUser }     = useDeleteUser();

  if (isLoading) return <TableSkeleton />;
  if (error)     return <ErrorMessage error={error} />;

  return (
    <table>
      {data.users.map((user) => (
        <tr key={user.id}>
          <td>{user.name}</td>
          <td>{user.email}</td>
          <td>
            <button onClick={() => deleteUser(user.id)}>Delete</button>
          </td>
        </tr>
      ))}
    </table>
  );
}

5. Retry Logic and Request Resilience#

Network requests fail. A production-grade API layer handles transient failures gracefully rather than surfacing them immediately to the user.

Automatic Retry with Exponential Backoff#

// lib/http.ts: enhanced with retry
async function requestWithRetry<T>(
  endpoint: string,
  options: RequestOptions & { maxRetries?: number } = {}
): Promise<ApiResponse<T>> {
  const { maxRetries = 3, ...requestOptions } = options;
  let lastError: Error;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await request<T>(endpoint, requestOptions);
    } catch (error) {
      lastError = error as Error;

      // Do not retry on client errors (4xx): they will not succeed on retry
      if (error instanceof ApiError && error.status < 500) {
        throw error;
      }

      // Exponential backoff: 200ms, 400ms, 800ms
      if (attempt < maxRetries - 1) {
        await new Promise((resolve) =>
          setTimeout(resolve, 200 * Math.pow(2, attempt))
        );
      }
    }
  }

  throw lastError!;
}

TanStack Query Retry Configuration#

// app/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        // Do not retry on 404 or 403
        if (error instanceof ApiError && [403, 404].includes(error.status)) {
          return false;
        }
        return failureCount < 2;
      },
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 10000),
    },
  },
});

6. Global Error Handling#

Certain API errors require global handling rather than component-level handling. A 401 Unauthorized response should redirect to the login page regardless of which component triggered the request. A 503 Service Unavailable should display a global maintenance banner.

// lib/http.ts: global error interceptor
function handleGlobalError(error: ApiError) {
  switch (error.status) {
    case 401:
      clearAuthToken();
      window.location.href = "/login?redirect=" + encodeURIComponent(window.location.pathname);
      break;
    case 503:
      window.dispatchEvent(new CustomEvent("api:maintenance"));
      break;
  }
}

// Add to the request function
if (!response.ok) {
  const apiError = new ApiError(errorBody.message, response.status, errorBody.code);
  handleGlobalError(apiError);
  throw apiError;
}

7. API Mocking for Development and Testing#

A frontend team should not be blocked by backend availability. Mock Service Worker (MSW) provides API mocking at the network level, intercepting actual fetch requests and returning mock responses without modifying application code.

// mocks/handlers.ts
import { http, HttpResponse } from "msw";

export const handlers = [
  http.get("/api/users/:id", ({ params }) => {
    return HttpResponse.json({
      id:        params.id,
      name:      "Alice Johnson",
      email:     "[email protected]",
      role:      "editor",
      createdAt: "2025-01-15T09:00:00Z",
    });
  }),

  http.delete("/api/users/:id", () => {
    return new HttpResponse(null, { status: 204 });
  }),
];
// mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

// Start in development
if (process.env.NODE_ENV === "development") {
  worker.start({ onUnhandledRequest: "warn" });
}

MSW works identically in the browser and in Node.js, meaning the same mock handlers work for both manual development and automated tests.

Conclusion#

A structured frontend API layer is not over-engineering. It is the foundation that makes everything built on top of it faster to develop, easier to test, and more resilient in production. The patterns in this guide, from the typed HTTP client and domain API modules to query key factories and MSW mocking, represent the standard of practice at teams that ship reliable frontend applications at scale.

The investment is highest upfront and pays off consistently from that point forward. Every new API endpoint added to a well-structured layer takes minutes. Every bug in that layer is easy to locate and fix in one place. Every new engineer who joins the team understands how data flows from server to UI without reading dozens of components.

Structure your API layer early. You will not regret it.