Comment j’ai construit une couche frontend de récupération de données typée avec TanStack Query, Zod et un BFF

Illustration de Comment j’ai construit une couche frontend de récupération de données typée avec TanStack Query, Zod et un BFF

Une architecture frontend pratique pour partager les contrats d’API, valider les réponses, charger les données pendant le SSR, les mettre en cache dans React, transmettre l’authentification par un BFF et synchroniser les mutations avec Symfony.

Avez-vous déjà ouvert un composant React et trouvé, dans le même fichier, un appel d’API, un jeton d’accès, une URL codée en dur, une assertion as SomeType et trois booléens de chargement sans rapport entre eux ?

Tout commence souvent de manière anodine :

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

Puis l’application grandit.

La même ressource est récupérée depuis le chargeur d’une route et depuis un composant. Les filtres doivent être sérialisés. Deux écrans inventent des clés de cache différentes. Une mutation réussit, mais la liste affiche toujours des données obsolètes. Le jeton d’accès expire. Le backend modifie un champ nullable, TypeScript ne signale rien et l’interface plante en production.

À ce stade, le problème n’est plus de savoir comment envoyer une requête HTTP. Il s’agit de définir un contrat commun au frontend et au backend, puis d’assurer sa cohérence pour chaque requête.

En développant une API Symfony consommée par des applications React, j’ai introduit une couche dédiée à la récupération des données. Ses responsabilités sont les suivantes :

  • exposer uniquement les opérations que chaque application est autorisée à appeler ;

  • définir les contrats de requête et de réponse avec Zod ;

  • centraliser les endpoints, la sérialisation et la configuration HTTP ;

  • générer les options TanStack Query et des clés de cache stables ;

  • fonctionner pendant le rendu côté serveur comme pendant la navigation dans le navigateur ;

  • acheminer les requêtes du navigateur par un Backend for Frontend, ou BFF, de même origine ;

  • normaliser les erreurs du backend sans masquer les détails utiles de validation ;

  • intégrer explicitement l’invalidation du cache à chaque processus d’écriture.

En tant que cofondateur, cette frontière m’intéresse au-delà de l’organisation du code : elle permet à l’équipe de livrer de nouvelles interfaces sans que chaque fonctionnalité doive redécouvrir le fonctionnement de mon backend.

Dans cet article, je vais reconstruire cette architecture avec des exemples génériques afin que vous puissiez la reproduire dans votre propre projet React et Symfony.

La récupération des données est une frontière applicative

L’interface doit exprimer une intention :

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

Elle ne devrait pas avoir à connaître :

  • si la requête utilise Axios ou fetch ;

  • la route Symfony exacte ;

  • la manière dont les filtres imbriqués deviennent une chaîne de requête ;

  • l’emplacement du jeton d’accès ;

  • la manière dont la réponse est validée ;

  • les autres ressources en cache qui deviennent obsolètes après une écriture.

Considérez la couche de données comme un adaptateur entre deux modèles :

Mermaid

Cette frontière est volontairement plus structurée qu’un client HTTP générique. Axios sait envoyer une requête. Il ignore qu’une liste d’utilisateurs et le détail d’un utilisateur sont liés, qu’une route est réservée aux administrateurs ou qu’une mise à jour réussie invalide ces deux projections.

Le parcours complet d’une requête

Mon application dispose de deux parcours vers la même API Symfony.

Pendant la navigation dans le navigateur :

Mermaid

Pendant le rendu côté serveur :

Mermaid

Le transport change, mais l’opération, le schéma de réponse, la clé de requête et le comportement du cache restent identiques.

C’est essentiel. Si le SSR utilise une abstraction d’API et les composants du navigateur une autre, le serveur risque de précharger les données sous une clé différente ou de renvoyer une structure différente. Le navigateur ignore alors le résultat préchargé et récupère immédiatement toutes les données une seconde fois.

Étape 1 : organiser le package d’API autour des domaines et des surfaces d’accès

Je regroupe les responsabilités liées à l’API dans un package partagé au lieu de les répartir entre les fonctionnalités :

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

Le domaine indique quelle capacité j’utilise. La surface indique comment l’acteur courant y accède.

Par exemple :

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

Une liste d’utilisateurs destinée à l’administration et un endpoint de gestion autonome du compte peuvent tous deux relever de l’identité, sans partager les mêmes règles d’autorisation ni les mêmes projections de réponse. Rendre la surface visible au point d’appel empêche ces différences de disparaître derrière une méthode vague comme api.users.list().

Étape 2 : utiliser les façades de plateforme comme listes de capacités

Chaque application frontend importe une façade de plateforme :

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,
  },
};

Notez que l’application principale n’expose pas du tout identity.admin.

Il ne s’agit pas d’un mécanisme de sécurité du backend. Symfony doit toujours autoriser chaque requête. La façade constitue une contrainte de conception pour les développeurs frontend : une capacité indisponible ne peut pas être importée par erreur depuis l’interface publique habituelle.

L’appel public reste lisible :

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

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

L’application choisit une plateforme. La fonctionnalité choisit un domaine et une surface d’accès. Le package partagé prend en charge tout ce qui se trouve sous cette décision.

Étape 3 : définir des contrats d’exécution avec Zod

Un type TypeScript disparaît à l’exécution. Ce code ne valide rien :

TypeScript
const data = response.data as ListUsersResponse;

Il demande seulement au compilateur de me faire confiance.

Définissez plutôt le contrat de transport sous la forme d’un schéma Zod, puis déduisez-en le type TypeScript :

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>;

Une réponse inattendue du backend échoue désormais à la frontière par laquelle elle entre dans l’application.

Zod peut également adapter les détails du transport à un modèle approprié pour le frontend :

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,
  }));

La transformation doit se trouver à côté du contrat, et non dans chaque consommateur.

J’obtiens ainsi des contrats frontend validés à l’exécution. PHP et TypeScript étant compilés séparément, cette approche ne garantit pas à elle seule que Symfony et React ne divergeront jamais. Je protège cette jonction avec des tests de contrat et pourrai ajouter ultérieurement une génération OpenAPI si le coût d’une synchronisation automatique entre les langages devient justifié.

Étape 4 : centraliser la topologie des endpoints

Les endpoints ne doivent pas être copiés dans les composants :

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`,
};

Un endpoint peut être une chaîne ou une fonction de paramètres de chemin :

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

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

La centralisation attribue la responsabilité des changements de route à un seul endroit. Elle rend aussi les cartes d’endpoints testables sans monter de composants React.

Étape 5 : configurer un seul client HTTP

Le client de bas niveau définit les valeurs par défaut du transport :

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;
  },
);

Quelques détails sont importants ici :

  • withCredentials permet au navigateur d’envoyer le cookie de session opaque du BFF.

  • L’en-tête de plateforme indique à Symfony quelle surface cliente a initié la requête.

  • FormData retire le type de contenu JSON configuré manuellement afin que le navigateur puisse ajouter la bonne délimitation multipart.

  • getRequestConfig est dynamique, car les requêtes rendues côté serveur et celles du navigateur nécessitent des URL de base et des en-têtes différents.

  • Le traitement des réponses 401 est centralisé au lieu d’être répété dans chaque écran.

Je peux aussi ajouter des observateurs de requêtes à des fins d’analyse ou de diagnostic, mais l’observabilité ne doit jamais pouvoir interrompre une requête.

Étape 6 : construire une fabrique de requêtes réutilisable

TanStack Query fonctionne mieux lorsque la clé et la fonction de requête sont définies ensemble. J’utilise une fabrique qui sait également sérialiser les paramètres et valider les réponses :

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,
  };
}

Cette fabrique fournit quatre interfaces utiles à chaque opération de lecture :

  • queryOptions(params) pour les composants React et les chargeurs de routes ;

  • queryKey(params) pour les opérations précises sur le cache ;

  • queryKeyPrefix() pour invalider une famille de résultats ;

  • request(params) pour un usage contrôlé en dehors de TanStack Query.

Transmettre l’AbortSignal de TanStack Query à Axios permet aussi aux navigations abandonnées d’annuler leurs requêtes, au lieu de laisser des traitements devenus inutiles s’exécuter.

Étape 7 : définir une opération une seule fois

La liste des utilisateurs devient déclarative :

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,
  }),
};

Les filtres font partie de la clé de requête. Ce n’est pas un détail administratif facultatif : ils définissent l’identité de la valeur mise en cache.

Ces ressources sont différentes :

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

Si j’utilisais uniquement ["users"], la navigation entre les pages pourrait afficher les données d’une autre requête.

Les clés de requête doivent être sérialisables et déterministes. Normalisez les dates sous forme de chaînes, omettez les valeurs vides sans signification et n’y placez ni instances de classes ni fonctions.

Étape 8 : modéliser les filtres avant de les sérialiser

Les tableaux combinent généralement pagination, tri, filtres de colonnes, plages et recherche. Définissez cette structure sous forme de données :

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(),
  }),
});

L’adaptateur du tableau convertit l’état de l’interface dans cette structure indépendante du transport. Seul le constructeur de requête décide de sa conversion en URL :

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

Cette séparation permet de modifier le tableau sans réécrire les définitions des endpoints, et de faire évoluer le langage de requête du backend sans diffuser la logique de sérialisation dans les composants React.

Étape 9 : précharger dans le chargeur de route

Avec TanStack Router, une route protégée peut valider la session et charger ses données principales avant le rendu :

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 renvoie les données en cache lorsqu’elles sont encore valides et les récupère si nécessaire. L’intégration SSR du routeur déshydrate le cache des requêtes dans la réponse, puis l’hydrate dans le navigateur.

Chaque instance du routeur doit recevoir son propre 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>
    ),
  });
}

Ne partagez jamais un QueryClient côté serveur entre plusieurs utilisateurs. Son cache contient les données des requêtes et une instance globale peut exposer la réponse d’un utilisateur dans le rendu d’un autre.

Je désactive les nouvelles tentatives automatiques par défaut, car leur usage relève d’une décision produit. Répéter un échec 404, 422 ou une erreur d’autorisation ajoute de la latence sans bénéfice, et relancer silencieusement une mutation peut dupliquer ses effets de bord. Les lectures idempotentes peuvent les activer individuellement lorsque cela se justifie.

Étape 10 : consommer la même opération dans React

Le composant utilise le même objet d’options :

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} />;
}

Si le chargeur a déjà récupéré la même clé, useQuery consomme le cache hydraté. Si l’utilisateur modifie les filtres, la clé change et TanStack Query récupère la ressource correspondante.

Le composant connaît le résultat métier et les états de l’interface. Il ignore l’URL et le transport.

Étape 11 : construire les mutations selon les mêmes règles

Les écritures nécessitent une validation des entrées, la résolution du chemin, un mapping facultatif du corps, la validation de la réponse et des options de mutation TanStack :

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,
  };
}

Définissez l’opération :

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,
});

Le schéma peut être réutilisé par le formulaire et à la frontière de la requête. Je déduis le type du payload au lieu de maintenir séparément un schéma, un type de formulaire et un type de transport.

Étape 12 : invalider ce que la mutation a modifié

Le serveur fait autorité. Après une mise à jour réussie, marquez comme obsolètes les projections concernées :

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.");
    },
  };
}

Le composant reste ainsi concis :

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)}
    />
  );
}

Invalider un préfixe actualise toutes les variantes en cache de la liste des utilisateurs, y compris les différentes pages et les différents filtres. Invalider le préfixe du détail met à jour toute vue détaillée ouverte ou consultée à nouveau.

N’invalidez pas l’intégralité du cache après chaque mutation. Cette pratique masque une connaissance incomplète des dépendances, génère du trafic réseau inutile et peut faire scintiller des écrans sans rapport. Nommez les projections réellement affectées par l’écriture.

Pour des écritures simples et peu fréquentes, l’invalidation est plus facile à raisonner que la modification manuelle de chaque liste en cache. Pour les interactions sensibles à la latence, une mise à jour optimiste peut être ajoutée, à condition de sauvegarder l’état précédent du cache et de le restaurer en cas d’erreur.

Étape 13 : acheminer les requêtes du navigateur par un BFF

Dans le navigateur, le client d’API utilise une URL de base relative :

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

L’application TanStack Start possède une route générique :

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,
    },
  },
});

Le BFF lit le cookie de session opaque, récupère les jetons côté serveur, retire les en-têtes de transfert sensibles contrôlés par le navigateur et ajoute des en-têtes fiables :

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

Si Symfony renvoie 401, le BFF peut renouveler le jeton de rafraîchissement une fois, puis relancer une seule fois la requête initiale. Si le renouvellement échoue, il efface la session.

Les composants React ne lisent, ne stockent, ne renouvellent et ne joignent jamais les jetons d’accès. De leur point de vue, ils appellent un endpoint /api de même origine.

Le BFF améliore la gestion des identifiants, mais ne remplace pas l’autorisation Symfony. La façade de plateforme, la protection de route et le BFF apportent de la commodité et une défense en profondeur. L’API reste l’autorité finale.

Étape 14 : utiliser une configuration de requête isomorphe

Le rendu serveur ne doit pas appeler l’URL publique du navigateur et reboucler sur lui-même. Il peut appeler Symfony directement avec des en-têtes de session fiables :

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(),
    };
  });

L’intercepteur HTTP partagé demande cette configuration avant chaque requête. La même opération de requête fonctionne donc dans les deux environnements :

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

Seule la configuration du transport change. Ne séparez pas les clients métier en browserApi et serverApi à moins que leurs capacités soient réellement différentes.

Étape 15 : normaliser les détails des problèmes et les erreurs de validation

Symfony peut renvoyer des erreurs selon une structure de détails de problème :

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."
    }
  ]
}

Analysez également cette frontière :

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 ?? [] : [];
}

L’adaptateur du formulaire peut associer propertyPath aux champs, tandis qu’une notification ou une frontière d’erreur affiche le détail général.

Ne réduisez pas chaque échec du backend à Something went wrong. Un conflit, une erreur de validation, une limite de débit, un échec d’authentification et une panne d’infrastructure appellent des actions différentes de la part de l’utilisateur. La normalisation doit créer une interface frontend stable sans perdre le sens de l’erreur.

Conservez aussi les échecs de validation du schéma visibles dans la surveillance. Lorsque le statut HTTP indique un succès mais que la réponse ne satisfait pas Zod, le contrat a divergé. Traitez ce cas comme un défaut d’intégration, et non comme un état vide.

Étape 16 : conserver les téléchargements dans la même frontière

Toutes les lectures n’ont pas leur place dans le cache des requêtes. Le téléchargement d’un rapport est une action qui produit un fichier :

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"],
        ),
      };
    },
  };
}

Le téléchargement bénéficie toujours du transport configuré, de la session BFF, de la centralisation de l’endpoint et du traitement des erreurs. Il ne prétend toutefois pas qu’un fichier enregistré par le navigateur constitue un état serveur à mettre en cache.

Étape 17 : tester les jonctions, pas Axios

Le package partagé doit disposer de tests ciblés sur les comportements dont il est responsable.

Tests de contrat

Vérifiez que les payloads Symfony valides sont analysés et que ceux qui sont mal formés échouent :

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

Tests du constructeur de requêtes

Utilisez un adaptateur de test et vérifiez que :

  • les paramètres de chemin sont résolus correctement ;

  • les chaînes de requête imbriquées sont stables ;

  • les clés de requête incluent tous les paramètres qui modifient le résultat ;

  • les variables des mutations sont validées avant l’appel réseau ;

  • les corps des requêtes sont transformés correctement ;

  • les réponses sont analysées à l’aide de leurs schémas ;

  • les signaux d’annulation atteignent le transport.

Tests des plateformes

Protégez la surface publique des capacités :

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

Tests des fonctionnalités

Au niveau de React, testez les comportements visibles par l’utilisateur : chargement, état vide, échec, mutation réussie, violations de champs et actualisation du cache. Simulez la frontière réseau au lieu de reproduire les détails d’implémentation de la fabrique de requêtes.

Tests du BFF

Vérifiez que les identifiants du navigateur ne peuvent pas remplacer les en-têtes d’autorisation fiables, que les requêtes protégées nécessitent une session, qu’un seul 401 déclenche au plus un renouvellement et qu’un renouvellement échoué efface la session.

Erreurs courantes dans la récupération des données

Considérer as Type comme une validation

Une assertion de type réduit le compilateur au silence. Analysez les données réseau inconnues avant de les laisser entrer dans l’application.

Construire les clés de cache dans les composants

Si chaque composant invente ses clés, l’invalidation devient approximative. L’opération qui définit la requête doit posséder sa clé et son préfixe.

Omettre les filtres de la clé

Toute entrée qui modifie le résultat doit aussi modifier la clé. Dans le cas contraire, des ressources distinctes entrent en collision dans le cache.

Gérer les jetons dans le code des fonctionnalités

Le stockage, l’ajout, le renouvellement des jetons et la déconnexion relèvent de la frontière de session et de transport. Un tableau ne devrait pas savoir ce qu’est un jeton de rafraîchissement.

Dupliquer les clients du serveur et du navigateur

Utilisez une seule opération métier avec une configuration de transport isomorphe. L’hydratation SSR et le rendu dans le navigateur partagent ainsi la même identité de cache.

Tout invalider

Une invalidation large est simple, mais coûteuse. Invalidez les projections de liste, de détail, de synthèse ou de référence affectées par la commande.

Relancer aveuglément les mutations

Une réponse perdue ne signifie pas que le serveur n’a rien fait. Ne relancez l’opération que si elle est idempotente ou utilise une clé d’idempotence.

Partager un client de requêtes entre des requêtes SSR

Un cache serveur global peut mélanger des données propres à plusieurs utilisateurs. Créez un client de requêtes pour chaque routeur ou cycle de vie de requête.

Masquer les échecs de contrat

Un échec Zod après une réponse 200 ne signifie pas « aucune donnée ». Il prouve que le frontend et le backend sont en désaccord.

Laisser la façade frontend remplacer l’autorisation

Masquer les opérations d’administration dans l’application principale améliore la conception, mais un attaquant peut toujours construire des requêtes HTTP. Symfony doit faire respecter les accès indépendamment.

Les compromis

Cette architecture introduit davantage de code qu’un appel direct à fetch :

  • les schémas doivent être maintenus ;

  • les opérations ont besoin de clés et de préfixes ;

  • les façades de plateforme doivent être composées de manière intentionnelle ;

  • les mutations doivent déclarer leurs effets de synchronisation.

Cet investissement devient rentable lorsque l’application possède plusieurs domaines, des surfaces authentifiées, du SSR, des filtres complexes et plusieurs développeurs qui ajoutent des fonctionnalités en parallèle.

Pour un petit site public doté de trois endpoints en lecture seule, cette structure peut être excessive. Commencez par la frontière dont vous avez besoin. La règle essentielle est d’empêcher les détails du transport de s’accumuler progressivement dans les composants produit.

Conclusion

Une couche de données frontend fiable n’est pas un simple wrapper Axios. C’est l’endroit où l’application du navigateur et le backend s’accordent sur les comportements.

  • Les façades de plateforme exposent des capacités frontend intentionnelles.

  • Les domaines et les surfaces d’accès rendent le point d’appel explicite.

  • Zod valide les entrées et les réponses inconnues à l’exécution.

  • Les cartes centralisées des endpoints possèdent la topologie des routes.

  • Les fabriques de requêtes regroupent les fonctions de requête, les clés, la sérialisation et les schémas.

  • Les chargeurs de TanStack Router et les composants React réutilisent les mêmes options de requête.

  • Un client de requêtes par requête sécurise l’hydratation SSR.

  • Le BFF maintient les jetons hors du code des fonctionnalités et du stockage du navigateur.

  • La configuration isomorphe achemine le trafic du navigateur par /api et celui du serveur directement vers Symfony.

  • Les mutations valident leurs variables et invalident explicitement les projections concernées.

  • Les utilitaires de détails de problème préservent les erreurs utiles et les violations de champs.

Le principal progrès n’a pas été de remplacer une bibliothèque HTTP par une autre. Il a consisté à attribuer clairement la responsabilité de la récupération des données.

Une fois cette frontière établie, les composants deviennent plus lisibles, les chargeurs de routes et la navigation dans le navigateur se comportent de manière cohérente, l’authentification ne se diffuse plus dans les fonctionnalités et les divergences du contrat backend échouent là où elles peuvent être diagnostiquées.

C’est à ce moment que l’intégration entre frontend et backend cesse d’être une collection de requêtes et devient une architecture.

Bon développement !

Articles liés