A type-safe 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.
A complete authentication flow from a type-safe login form to Symfony Security, rotating refresh tokens, server-side web sessions, protected API requests, logout, recovery, and federated login.
Modeling structured domain values in PHP, persisting them as PostgreSQL JSONB through custom Doctrine types, and validating the same contract in TypeScript with Zod.
How to build a durable payment workflow around FlexPay using Orders, Payment Transactions, signed webhooks, idempotent fulfillment, and a migration path from a legacy billing model.
Read
Need a software engineer?
Send the goal, stack, and timeline. I'll reply with a next step.
A React component can easily accumulate an API call, access token, hard-coded URL, unchecked as SomeType assertion, and several loading flags. Once that pattern spreads, components no longer have one clear job.
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 product features without making each one rediscover how the backend works.
The generic examples below build the same boundary for a React and Symfony project.
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.
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.
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. Naming the API namespace at the call site keeps that difference visible instead of hiding it behind api.users.list().
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 API namespace. The shared package owns the request details below that decision.
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
exportconst 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.
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:
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.
This separation lets the table change without rewriting endpoint definitions, and lets the backend query language evolve without leaking serializer logic into React components.
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:
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.
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.
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.
The server is authoritative. After a successful update, mark affected projections stale:
TypeScript
functionuseUserMutationCallbacks(){const queryClient =useQueryClient();return{onError(error:unknown){
toast.error(getErrorMessage(error,"Unable to update the user."));},asynconSuccess(){awaitPromise.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
functionEditUserForm({ user }:{ user: User }){const mutation =useMutation(
api.identity.admin.updateUserProfile.mutationOptions({ userId: user.userId },useUserMutationCallbacks(),),);return(<UserFormerror={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.
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.
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."}]}
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.
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.
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.
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.
Hiding admin operations from the main application improves design, but an attacker can still construct HTTP requests. Symfony must enforce access independently.
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, actor-specific APIs, 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.
Domains and API namespaces 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 useful change was giving data fetching a clear owner, not replacing one HTTP library with another. Components now focus on interface behavior. Route loaders and browser navigation share request rules, authentication stays out of features, and contract drift fails at a boundary that can explain the mismatch.