How I Built a Type-Safe Frontend Data-Fetching Layer with TanStack Query, Zod, and a BFF

Cover image for How I Built a Type-Safe Frontend Data-Fetching Layer with TanStack Query, Zod, and a BFF

A practical frontend architecture for sharing API contracts, validating responses, fetching during SSR, caching in React, forwarding authentication through a BFF, and keeping mutations synchronized with Symfony.

Ever opened a React component and found an API call, an access token, a hard-coded URL, an as SomeType assertion, and three unrelated loading booleans living in the same file?

It often starts innocently:

TypeScript
const response = await fetch("https://api.example.test/users");
const users = (await response.json()) as User[];

Then the application grows.

The same resource is fetched from a route loader and a component. Filters need to be serialized. Two screens invent different cache keys. A mutation succeeds, but the list still displays stale data. The access token expires. The backend changes a nullable field, TypeScript remains happy, and the UI crashes in production.

At that point, the problem is no longer how to send an HTTP request. The problem is how the frontend and backend agree on a contract and keep that agreement consistent across every request.

While building a Symfony API consumed by React applications, I introduced a dedicated data-fetching layer. Its responsibilities are to:

  • expose only the operations each application is allowed to call;

  • define request and response contracts with Zod;

  • centralize endpoints, serialization, and HTTP configuration;

  • generate TanStack Query options and stable cache keys;

  • work during both server-side rendering and browser navigation;

  • route browser requests through a same-origin Backend for Frontend, or BFF;

  • normalize backend errors without hiding useful validation details;

  • and make cache invalidation an explicit part of every write workflow.

As a co-founder, I care about this boundary beyond code organization: it lets the team ship new product surfaces without making every feature rediscover how my backend works.

In this post, I’ll build the same architecture with generic examples so you can reproduce it in your own React and Symfony project.

Data Fetching Is an Application Boundary

The UI should express intent:

TypeScript
api.identity.admin.listUsers.queryOptions(filters);

It should not need to know:

  • whether the request uses Axios or fetch;

  • the exact Symfony route;

  • how nested filters become a query string;

  • where the access token is stored;

  • how the response is validated;

  • or which other cached resources become stale after a write.

Think of the data layer as an adapter between two models:

Mermaid

This boundary is deliberately more opinionated than a generic HTTP client. Axios knows how to send a request. It does not know that a user list and a user detail are related, that one route is admin-only, or that a successful update invalidates both projections.

The Complete Request Journey

My application has two paths to the same Symfony API.

During browser navigation:

Mermaid

During server-side rendering:

Mermaid

The transport changes, but the operation, response schema, query key, and cache behavior do not.

That is important. If SSR uses one API abstraction and browser components use another, the server may preload data under a different key or return a different shape. The browser then ignores the prefetched result and immediately fetches everything again.

Step 1: Organize the API Package around Domains and Access Surfaces

I keep API concerns in a shared package instead of distributing them across features:

Plain text
packages/api/src/
├── platforms/
│   ├── admin.ts
│   └── main.ts
├── contracts/
│   └── identity/
│       ├── admin.ts
│       └── account.ts
├── core/
│   ├── client.ts
│   └── errors.ts
├── endpoints/
│   └── index.ts
└── internal/
    ├── client/
    │   ├── http-client.ts
    │   └── request.ts
    └── identity/
        ├── contracts.ts
        ├── endpoints.ts
        └── index.ts

The domain tells me what capability I am using. The surface tells me how the current actor accesses it.

For example:

TypeScript
admin.identity.admin.listUsers;
admin.identity.account.getSettings;
main.identity.account.getSettings;
main.identity.auth.login;

An administrative user list and a self-service account endpoint may both belong to identity, but they do not have the same authorization rules or response projections. Making the surface visible at the call site prevents these differences from disappearing behind a vague api.users.list() method.

Step 2: Use Platform Facades as Capability Lists

Each frontend application imports a platform facade:

TypeScript
// platforms/admin.ts
export const admin = {
  identity: {
    account: identityAccountApi,
    admin: identityAdminApi,
    auth: identityAuthApi,
  },
  billing: {
    admin: billingAdminApi,
    public: billingPublicApi,
  },
};
TypeScript
// platforms/main.ts
export const main = {
  identity: {
    account: identityAccountApi,
    auth: identityAuthApi,
  },
  billing: {
    public: billingPublicApi,
    selfService: billingSelfServiceApi,
  },
};

Notice that the main application does not expose identity.admin at all.

This is not backend security. Symfony must still authorize every request. The facade is a design constraint for frontend engineers: an unavailable capability cannot be imported accidentally through the normal public interface.

The public call remains readable:

TypeScript
import { admin as api } from "@app/api/platforms/admin";

api.identity.admin.listUsers.queryOptions(filters);

The application chooses a platform. The feature chooses a domain and access surface. The shared package owns everything below that decision.

Step 3: Define Runtime Contracts with Zod

A TypeScript type disappears at runtime. This code does not validate anything:

TypeScript
const data = response.data as ListUsersResponse;

It only asks the compiler to trust me.

Instead, define the transport contract as a Zod schema and infer the TypeScript type from it:

TypeScript
import { z } from "zod";

export const userSchema = z.object({
  userId: z.string().uuid(),
  name: z.string(),
  email: z.email(),
  status: z.enum(["active", "suspended", "disabled"]),
  roles: z.array(z.string()),
  createdAt: z.string().datetime(),
  lastLoginAt: z.string().datetime().nullable(),
});

export const paginationSchema = z.object({
  current: z.number().int().positive(),
  limit: z.number().int().positive(),
  pages: z.number().int().min(0),
  total: z.number().int().min(0),
});

export const listUsersResponseSchema = z.object({
  items: z.array(userSchema),
  pagination: paginationSchema,
});

export type User = z.infer<typeof userSchema>;
export type ListUsersResponse = z.infer<typeof listUsersResponseSchema>;

Now an unexpected backend response fails at the boundary where it entered the application.

Zod can also adapt transport details into a frontend-friendly model:

TypeScript
export const loginResponseSchema = z
  .object({
    token: z.string().min(1),
    refreshToken: z.string().min(1),
    user: userSchema,
  })
  .transform(({ token, refreshToken, user }) => ({
    accessToken: token,
    refreshToken,
    user,
  }));

The transformation belongs beside the contract, not in every consumer.

This gives me runtime-validated frontend contracts. Because PHP and TypeScript are compiled separately, it does not magically guarantee that Symfony and React can never drift. I protect that seam with contract tests and can add OpenAPI generation later if automatic cross-language synchronization becomes worth the cost.

Step 4: Centralize Endpoint Topology

Endpoints should not be copied into components:

TypeScript
export const identityEndpoints = {
  users: "/identity/admin/users",
  user: ({ userId }: { userId: string }) =>
    `/identity/admin/users/${userId}`,
  updateUserProfile: ({ userId }: { userId: string }) =>
    `/identity/admin/users/${userId}/profile`,
};

An endpoint may be a string or a function of path parameters:

TypeScript
type Endpoint<TParams> = string | ((params: TParams) => string);

function resolvePath<TParams>(endpoint: Endpoint<TParams>, params: TParams) {
  return typeof endpoint === "function" ? endpoint(params) : endpoint;
}

Centralization gives route changes one owner. It also makes endpoint maps testable without mounting React components.

Step 5: Configure One HTTP Client

The low-level client owns transport defaults:

TypeScript
import axios, { AxiosHeaders } from "axios";

type ApiClientConfiguration = {
  baseURL?: string;
  clientPlatform: "admin" | "main";
  getRequestConfig?: () =>
    | Promise<AxiosRequestConfig>
    | AxiosRequestConfig;
  onUnauthorized?: () => void;
};

let configuration: ApiClientConfiguration;

export const httpClient = axios.create({
  baseURL: "/api",
  headers: {
    Accept: "application/json",
    "Content-Type": "application/json",
  },
  withCredentials: true,
});

export function configureApiClient(next: ApiClientConfiguration) {
  configuration = next;
  httpClient.defaults.baseURL = next.baseURL || "/api";
}

httpClient.interceptors.request.use(async (request) => {
  const dynamicConfig = await configuration.getRequestConfig?.();

  request.baseURL = dynamicConfig?.baseURL ?? request.baseURL;
  request.withCredentials = true;
  request.headers = AxiosHeaders.from({
    ...dynamicConfig?.headers,
    ...request.headers,
  });
  request.headers.set("X-Client-Platform", configuration.clientPlatform);

  if (request.data instanceof FormData) {
    request.headers.delete("Content-Type");
  }

  return request;
});

httpClient.interceptors.response.use(
  (response) => response,
  (error) => {
    if (axios.isAxiosError(error) && error.response?.status === 401) {
      configuration.onUnauthorized?.();
    }

    throw error;
  },
);

There are a few important details here:

  • withCredentials lets the browser send the opaque BFF session cookie.

  • The platform header tells Symfony which client surface initiated the request.

  • FormData removes the manually configured JSON content type so the browser can add the correct multipart boundary.

  • getRequestConfig is dynamic because server-rendered and browser requests need different base URLs and headers.

  • 401 behavior is centralized instead of being repeated by every screen.

I can also add request observers for analytics or diagnostics, but observability must never be able to break a request.

Step 6: Build a Reusable Query Factory

TanStack Query works best when the query key and query function are defined together. I use a factory that also knows how to serialize parameters and validate responses:

TypeScript
import {
  type QueryKey,
  queryOptions,
} from "@tanstack/react-query";
import qs from "qs";
import type { z } from "zod";

type QueryBuilder<TParams, TResult> = {
  enabled?: (params: TParams) => boolean;
  key: (params: TParams) => QueryKey;
  keyPrefix?: QueryKey;
  path: Endpoint<TParams>;
  query?: (params: TParams) => unknown;
  responseSchema: z.ZodType<TResult>;
  staleTime?: number;
};

const DEFAULT_STALE_TIME = 10 * 60 * 1000;

function appendQueryString(path: string, params: unknown) {
  const queryString = qs.stringify(params, {
    encodeValuesOnly: true,
    skipNulls: true,
  });

  return queryString ? `${path}?${queryString}` : path;
}

export function createQuery<TParams, TResult>(builder: QueryBuilder<TParams, TResult>) {
  const request = async (params: TParams, signal?: AbortSignal) => {
    const path = appendQueryString(
      resolvePath(builder.path, params),
      builder.query?.(params),
    );
    const response = await httpClient.get<unknown>(path, { signal });

    return builder.responseSchema.parse(response.data);
  };

  return {
    endpoint: builder.path,
    queryKey: (params: TParams) => builder.key(params),
    queryKeyPrefix: () => builder.keyPrefix ?? [],
    queryOptions: (params: TParams) =>
      queryOptions({
        enabled: builder.enabled?.(params) ?? true,
        queryFn: ({ signal }) => request(params, signal),
        queryKey: builder.key(params),
        staleTime: builder.staleTime ?? DEFAULT_STALE_TIME,
      }),
    request,
  };
}

This factory gives every read operation four useful interfaces:

  • queryOptions(params) for React components and route loaders;

  • queryKey(params) for exact cache operations;

  • queryKeyPrefix() for invalidating a family of results;

  • request(params) for controlled use outside TanStack Query.

Passing TanStack Query’s AbortSignal to Axios also means abandoned navigations can cancel requests instead of leaving irrelevant work running.

Step 7: Define an Operation Once

The user list now becomes declarative:

TypeScript
const usersQueryKeyPrefix = ["identity", "users"] as const;

export const identityAdminApi = {
  listUsers: createQuery<PaginationFilters, ListUsersResponse>({
    key: (filters) => [...usersQueryKeyPrefix, filters],
    keyPrefix: usersQueryKeyPrefix,
    path: identityEndpoints.users,
    query: (filters) => filters,
    responseSchema: listUsersResponseSchema,
  }),
};

The filters are part of the query key. That is not optional bookkeeping; it is the identity of the cached value.

These are different resources:

TypeScript
["identity", "users", { page: { current: 1, limit: 20 } }]
["identity", "users", { page: { current: 2, limit: 20 } }]
["identity", "users", { filters: { search: { query: "Ada" } } }]

If I used only ["users"], navigating between pages could display data belonging to a different request.

Query keys must be serializable and deterministic. Normalize dates to strings, omit meaningless empty values, and avoid putting class instances or functions in them.

Step 8: Model Filters before Serializing Them

Tables usually combine pagination, sorting, column filters, ranges, and search. Define that structure as data:

TypeScript
export const paginationFiltersSchema = z.object({
  filters: z
    .object({
      columns: z
        .array(
          z.object({
            field: z.string().min(1),
            value: z.union([
              z.string(),
              z.number(),
              z.boolean(),
              z.array(z.string()),
            ]),
          }),
        )
        .optional(),
      search: z
        .object({
          fields: z.array(z.string().min(1)),
          query: z.string().min(1),
        })
        .optional(),
      sort: z
        .object({
          field: z.string().min(1),
          direction: z.enum(["asc", "desc"]),
        })
        .optional(),
    })
    .optional(),
  page: z.object({
    current: z.number().int().positive(),
    limit: z.number().int().positive(),
  }),
});

The table adapter converts UI state into this transport-neutral structure. Only the request builder decides how it becomes a URL:

Plain text
?page[current]=2
&page[limit]=20
&filters[search][query]=Ada
&filters[search][fields][0]=name
&filters[search][fields][1]=email

This separation lets the table change without rewriting endpoint definitions, and lets the backend query language evolve without leaking serializer logic into React components.

Step 9: Prefetch in the Route Loader

With TanStack Router, a protected route can validate the session and load its primary data before rendering:

TypeScript
export const Route = createFileRoute("/identity/users/")({
  beforeLoad: async ({ context }) => {
    await requireGrantedSession(context, ["ROLE_USER_MANAGER"]);
  },
  loader: async ({ context }) => {
    const filters = buildInitialUserFilters();

    await context.queryClient.ensureQueryData(
      api.identity.admin.listUsers.queryOptions(filters),
    );
  },
  component: UsersPage,
});

ensureQueryData returns cached data when it is still valid and fetches when necessary. The router’s SSR integration dehydrates the query cache into the response and hydrates it in the browser.

Each router instance must receive its own QueryClient:

TypeScript
export function createQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });
}

export function getRouter() {
  const queryClient = createQueryClient();

  return createRouter({
    context: { queryClient },
    routeTree,
    Wrap: ({ children }) => (
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    ),
  });
}

Never share one server-side QueryClient between users. Its cache contains request data, and a global instance can leak one user’s response into another user’s render.

I disable automatic retries by default because retries are a product decision. Repeating a 404, 422, or permission failure creates latency without value, and silently retrying a mutation can duplicate side effects. Individual idempotent reads can opt in when appropriate.

Step 10: Consume the Same Operation in React

The component uses the same options object:

TSX
import { useQuery } from "@tanstack/react-query";

function UsersTable({ filters }: { filters: PaginationFilters }) {
  const users = useQuery(
    api.identity.admin.listUsers.queryOptions(filters),
  );

  if (users.isPending) {
    return <UsersTableSkeleton />;
  }

  if (users.isError) {
    return <ErrorState error={users.error} />;
  }

  return <DataTable rows={users.data.items} />;
}

If the loader already fetched the same key, useQuery consumes the hydrated cache. If the user changes filters, the key changes and TanStack Query fetches the corresponding resource.

The component knows the domain result and UI states. It does not know the URL or transport.

Step 11: Build Mutations with the Same Rules

Writes need input validation, path resolution, optional body mapping, response validation, and TanStack mutation options:

TypeScript
type MutationBuilder<TParams, TVariables, TResult> = {
  body?: (variables: TVariables) => unknown;
  method: "post" | "put" | "patch" | "delete";
  path: Endpoint<TParams>;
  variablesSchema?: z.ZodType<TVariables>;
  responseSchema: z.ZodType<TResult>;
};

export function createMutation<TParams, TVariables, TResult>(
  builder: MutationBuilder<TParams, TVariables, TResult>,
) {
  const request = async (params: TParams, variables: TVariables) => {
    const validVariables = builder.variablesSchema
      ? builder.variablesSchema.parse(variables)
      : variables;
    const body = builder.body?.(validVariables) ?? validVariables;
    const path = resolvePath(builder.path, params);
    const response =
      builder.method === "delete"
        ? await httpClient.delete<unknown>(path, { data: body })
        : await httpClient[builder.method]<unknown>(path, body);

    return builder.responseSchema.parse(response.data);
  };

  return {
    endpoint: builder.path,
    mutationOptions: (params: TParams, overrides = {}) =>
      mutationOptions({
        mutationFn: (variables: TVariables) => request(params, variables),
        ...overrides,
      }),
    request,
  };
}

Define the operation:

TypeScript
export const updateUserProfileSchema = z.object({
  name: z.string().trim().min(1),
  phoneNumber: z.string().nullable(),
});

const emptyResponseSchema = z.unknown().transform(() => undefined);

export const updateUserProfile = createMutation<
  { userId: string },
  z.infer<typeof updateUserProfileSchema>,
  void
>({
  method: "patch",
  path: identityEndpoints.updateUserProfile,
  variablesSchema: updateUserProfileSchema,
  responseSchema: emptyResponseSchema,
});

The schema can be reused by the form and the request boundary. I infer the payload type instead of maintaining a schema, form type, and transport type separately.

Step 12: Invalidate What the Mutation Changed

The server is authoritative. After a successful update, mark affected projections stale:

TypeScript
function useUserMutationCallbacks() {
  const queryClient = useQueryClient();

  return {
    onError(error: unknown) {
      toast.error(getErrorMessage(error, "Unable to update the user."));
    },
    async onSuccess() {
      await Promise.all([
        queryClient.invalidateQueries({
          queryKey: api.identity.admin.listUsers.queryKeyPrefix(),
        }),
        queryClient.invalidateQueries({
          queryKey: api.identity.admin.getUserDetails.queryKeyPrefix(),
        }),
      ]);

      toast.success("User updated.");
    },
  };
}

Then the component stays small:

TSX
function EditUserForm({ user }: { user: User }) {
  const mutation = useMutation(
    api.identity.admin.updateUserProfile.mutationOptions(
      { userId: user.userId },
      useUserMutationCallbacks(),
    ),
  );

  return (
    <UserForm
      error={mutation.error}
      pending={mutation.isPending}
      onSubmit={(values) => mutation.mutate(values)}
    />
  );
}

Invalidating a prefix refreshes every cached variant of the user list, including different pages and filters. Invalidating the detail prefix updates any open or revisited detail view.

Do not invalidate the entire cache after every mutation. It hides missing dependency knowledge, causes unnecessary network traffic, and can make unrelated screens flicker. Name the projections the write actually affects.

For simple and low-frequency writes, invalidation is easier to reason about than manually editing every cached list. For latency-sensitive interactions, an optimistic update can be added, but it must snapshot the previous cache and roll back on error.

Step 13: Route Browser Requests through a BFF

In the browser, the API client uses a relative base URL:

TypeScript
configureApiClient({
  baseURL: "/api",
  clientPlatform: "admin",
  getRequestConfig: () => ({
    baseURL: "/api",
    withCredentials: true,
  }),
  onUnauthorized: redirectToLogin,
});

The TanStack Start application owns a catch-all route:

TypeScript
const forward = ({ params, request }: ForwardArgs) =>
  session.forwardSymfonyRequest({
    path: `/api/${params._splat}`,
    request,
  });

export const Route = createFileRoute("/api/$")({
  server: {
    handlers: {
      GET: forward,
      POST: forward,
      PUT: forward,
      PATCH: forward,
      DELETE: forward,
    },
  },
});

The BFF reads the opaque session cookie, retrieves server-side tokens, removes sensitive browser-controlled forwarding headers, and adds trusted headers:

TypeScript
headers.delete("authorization");
headers.delete("cookie");
headers.delete("host");
headers.delete("x-client-platform");

headers.set("Authorization", `Bearer ${session.accessToken}`);
headers.set("X-Client-Platform", platform);

If Symfony returns 401, the BFF can rotate the refresh token once and retry the original request once. If refresh fails, it clears the session.

React components never read, store, refresh, or attach access tokens. From their perspective, they call a same-origin /api endpoint.

The BFF improves credential handling, but it does not replace Symfony authorization. The platform facade, route guard, and BFF are convenience and defense-in-depth. The API remains the final authority.

Step 14: Use an Isomorphic Request Configuration

Server rendering should not call the public browser URL and loop through itself. It can call Symfony directly with trusted session headers:

TypeScript
export const getApiRequestConfig = createIsomorphicFn()
  .client(() => ({
    baseURL: "/api",
    withCredentials: true,
  }))
  .server(async () => {
    const { getServerApiHeaders } = await import("./server-session");

    return {
      baseURL: process.env.INTERNAL_API_URL,
      headers: getServerApiHeaders(),
    };
  });

The shared HTTP interceptor asks for this configuration before every request. The same query operation therefore works in both environments:

Plain text
Browser: /api/identity/admin/users
Server:  http://api.internal/identity/admin/users

Only transport configuration changes. Do not fork domain clients into browserApi and serverApi unless their capabilities are genuinely different.

Step 15: Normalize Problem Details and Validation Errors

Symfony can return errors using a problem-details shape:

JSON
{
  "type": "https://example.test/problems/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "The submitted data is invalid.",
  "violations": [
    {
      "propertyPath": "email",
      "title": "This email is already used."
    }
  ]
}

Parse this boundary too:

TypeScript
const validationViolationSchema = z.object({
  propertyPath: z.string(),
  title: z.string(),
});

const apiProblemSchema = z.looseObject({
  detail: z.string().optional(),
  message: z.string().optional(),
  status: z.number().optional(),
  violations: z.array(validationViolationSchema).optional(),
});

export function getErrorMessage(
  error: unknown,
  fallback = "Unable to complete the request.",
) {
  if (!axios.isAxiosError(error)) {
    return error instanceof Error ? error.message : fallback;
  }

  const problem = apiProblemSchema.safeParse(error.response?.data);

  if (problem.success) {
    return problem.data.detail ?? problem.data.message ?? fallback;
  }

  return fallback;
}

export function getValidationViolations(error: unknown) {
  if (!axios.isAxiosError(error) || error.response?.status !== 422) {
    return [];
  }

  const problem = apiProblemSchema.safeParse(error.response.data);

  return problem.success ? problem.data.violations ?? [] : [];
}

The form adapter can map propertyPath to fields, while a toast or error boundary displays the general detail.

Do not flatten every backend failure into Something went wrong. A conflict, validation error, rate limit, authentication failure, and infrastructure outage lead to different user actions. Normalization should create a stable frontend interface without destroying meaning.

Also keep schema-validation failures visible in monitoring. When the HTTP status is successful but the response does not satisfy Zod, the contract has drifted. Treat that as an integration defect, not as an empty state.

Step 16: Keep Downloads inside the Same Boundary

Not every read belongs in the query cache. A downloaded report is an action that produces a file:

TypeScript
export function createDownload<TParams>({ path, query }: DownloadBuilder<TParams>) {
  return {
    async download(params: TParams) {
      const response = await httpClient.get<Blob>(
        appendQueryString(resolvePath(path, params), query?.(params)),
        { responseType: "blob" },
      );

      return {
        blob: response.data,
        filename: parseContentDispositionFilename(
          response.headers["content-disposition"],
        ),
      };
    },
  };
}

The download still benefits from the configured transport, BFF session, endpoint ownership, and error behavior, but it does not pretend a browser file save is server state worth caching.

Step 17: Test the Seams, Not Axios

The shared package should have focused tests around the behavior it owns.

Contract Tests

Verify valid Symfony payloads parse and malformed payloads fail:

TypeScript
expect(listUsersResponseSchema.parse(validResponse)).toEqual(expected);
expect(() => listUsersResponseSchema.parse(invalidResponse)).toThrow();

Request-Builder Tests

Use a test adapter and assert:

  • path parameters resolve correctly;

  • nested query strings are stable;

  • query keys include every result-changing parameter;

  • mutation variables are validated before the network call;

  • request bodies are transformed correctly;

  • responses are parsed through their schemas;

  • and abort signals reach the transport.

Platform Tests

Protect the public capability surface:

TypeScript
expect(admin.identity.admin.listUsers).toBeDefined();
expect(main.identity).not.toHaveProperty("admin");

Feature Tests

At the React level, test user-visible behavior: loading, empty, failure, successful mutation, field violations, and cache refresh. Mock the network boundary rather than duplicating implementation details of the query factory.

BFF Tests

Verify that browser credentials cannot override trusted authorization headers, protected requests require a session, a single 401 triggers at most one refresh, and failed refresh clears the session.

Common Data-Fetching Mistakes

Treating as Type as Validation

A type assertion silences the compiler. Parse unknown network data before allowing it into the application.

Building Cache Keys inside Components

If every component invents keys, invalidation becomes guesswork. The operation that defines the query should own its key and prefix.

Leaving Filters out of the Key

Every input that changes the result must change the key. Otherwise, distinct resources collide in the cache.

Managing Tokens in Feature Code

Token storage, attachment, refresh, and logout belong to the session and transport boundary. A table should not know what a refresh token is.

Duplicating Server and Browser Clients

Use one domain operation with an isomorphic transport configuration. This lets SSR hydration and browser rendering share cache identity.

Invalidating Everything

Broad invalidation is easy but expensive. Invalidate the list, detail, summary, or reference projections affected by the command.

Retrying Mutations Blindly

A lost response does not mean the server did nothing. Retry only when the operation is idempotent or uses an idempotency key.

Sharing a Query Client across SSR Requests

A global server cache can mix user-specific data. Create a query client for each router or request lifecycle.

Swallowing Contract Failures

A Zod failure after a 200 response is not “no data.” It is evidence that the frontend and backend disagree.

Letting the Frontend Facade Replace Authorization

Hiding admin operations from the main application improves design, but an attacker can still construct HTTP requests. Symfony must enforce access independently.

The Trade-Offs

This architecture introduces more code than calling fetch directly:

  • schemas must be maintained;

  • operations need keys and prefixes;

  • platform facades must be composed intentionally;

  • and mutations must declare their synchronization effects.

That investment pays off when the application has multiple domains, authenticated surfaces, SSR, complex filters, and several engineers adding features in parallel.

For a small public website with three read-only endpoints, this may be too much structure. Start with the boundary you need. The important rule is that transport details should not grow organically inside product components.

Wrapping Up

A reliable frontend data layer is not an Axios wrapper. It is the place where the browser application and backend agree on behavior.

  • Platform facades expose intentional frontend capabilities.

  • Domains and access surfaces keep the call site explicit.

  • Zod validates inputs and unknown responses at runtime.

  • Central endpoint maps own route topology.

  • Query factories keep request functions, keys, serialization, and schemas together.

  • TanStack Router loaders and React components reuse the same query options.

  • A per-request query client makes SSR hydration safe.

  • The BFF keeps tokens away from feature code and browser storage.

  • Isomorphic configuration sends browser traffic through /api and server traffic directly to Symfony.

  • Mutations validate variables and explicitly invalidate affected projections.

  • Problem-details helpers preserve useful errors and field violations.

The biggest improvement was not replacing one HTTP library with another. It was giving data fetching a clear owner.

Once that boundary exists, components become easier to read, route loaders and browser navigation behave consistently, authentication stops leaking into features, and backend contract drift fails where it can be diagnosed.

That is when frontend and backend integration stops being a collection of requests and becomes an architecture.

Happy coding!

Related writing