6 min de lecture
Interroger le CMS une fois, et non une fois par page
Ce site demandait à Strapi le même contenu pour chaque page qu'il générait. Désormais un loader récupère chaque source une fois par build et chaque page lit un magasin local. Voici ce qui a changé, ce que cela a supprimé, et les trois pièges rencontrés.
Chaque page de ce site est prérendue. Il n'y a pas de serveur au moment de la requête, donc toute lecture du CMS se fait pendant le build — cela a toujours été vrai et le reste. Ce qui n'allait pas, c'était le nombre de fois où cela se produisait.
La mise en page partagée affiche un pied de page qui a besoin de mon profil. Le profil était donc récupéré une fois par page. Pas une fois par build : une fois par page. Ce site génère 136 pages, si bien qu'un type de contenu qui change peut-être deux fois par an était tiré de Strapi 136 fois par déploiement. Les projets étaient récupérés par l'index des projets, puis à nouveau par chaque page de détail, puis encore par l'index de recherche. Deux fonctionnalités avaient fini par se doter de caches de promesses écrits à la main uniquement pour masquer le problème.
flowchart LR
p1["/ "] --> d1["data.ts"]
p2["/projets"] --> d2["data.ts"]
p3["…134 autres pages"] --> d3["data.ts"]
d1 --> cms[("Strapi")]
d2 --> cms
d3 --> cms
Un loader par source
Le Content Layer d'Astro inverse cette logique. Au lieu qu'une page tire les données au moment du rendu, on déclare une collection dotée d'un loader, et ce loader s'exécute une fois par synchronisation — avant tout rendu — en écrivant ses résultats dans un magasin de contenu local. Les pages lisent ensuite ce magasin.
Il y a maintenant quatorze collections, une par source du CMS. Tout content.config.ts
n'est qu'un manifeste ; la récupération reste dans les modules qui l'ont toujours portée.
flowchart TD
subgraph sync["Synchronisation — une fois par build"]
loader["Loader"] --> api["api.ts"]
api --> cms[("Strapi")]
loader --> store[("Magasin de contenu")]
end
subgraph render["Rendu — une fois par page"]
pages["136 pages"] --> store
end
Les fonctions d'accès ont gardé leurs noms et leurs signatures : pas une seule page ni un
seul composant n'a été modifié. getProfile(locale) renvoie toujours un profil — elle lit
simplement une entrée au lieu d'émettre une requête.
Les identifiants d'entrée portent la locale : en/abc123, fr/abc123. Un consommateur
choisit une locale par un filtre sur le préfixe, et comme l'identifiant est la locale
plus la clé, il n'existe aucun champ de locale séparé susceptible de le contredire. Les
types uniques se passent de clé et utilisent un simple en / fr, ce qui transforme la
lecture en accès direct plutôt qu'en recherche.
Un détail qui paraît tatillon mais ne l'est pas : chaque entrée stocke un champ order
explicite, tiré de sa position dans la réponse déjà triée de l'API. L'ordre d'itération du
magasin est un détail d'implémentation, et s'y fier signifierait que le tri demandé à
Strapi pourrait cesser discrètement de survivre au trajet.
Trois choses qui ont disparu
Le plus intéressant dans cette migration, c'est la quantité de code supprimée plutôt qu'ajoutée.
Les caches sont partis. Les deux caches de promesses n'existaient que pour éviter des récupérations répétées. Avec le magasin, il n'y a plus rien à mémoïser : ils ont disparu, ainsi que les commentaires expliquant pourquoi ils étaient nécessaires.
Les requêtes filtrées en doublon sont parties. « Donne-moi seulement les projets mis en
avant » était une seconde requête avec un filtre. C'est désormais un .filter() sur un
ensemble que le loader possède déjà. Idem pour les compétences, idem pour les temps forts
de la frise sur la page d'accueil.
Le repli de traduction est devenu honnête. Un document sans ligne pour une locale
signifiait auparavant un 404 de Strapi, intercepté par classe d'erreur, avec repli sur la
locale par défaut. Cela signifie maintenant qu'il n'y a pas d'entrée à fr/abc123. Une
donnée absente exprimée comme une absence, et non comme une exception :
const localized = await getEntry('projects', `${locale}/${canonical.documentId}`);
return localized?.data ?? canonical;
Trois pièges
astro check exige désormais un CMS joignable, ce qui m'a surpris. La vérification de
types ne dépend pas des données — getCollection('projects') est typé depuis le schéma
de la collection, pas depuis les lignes. Mais astro check lance d'abord une
synchronisation, et celle-ci fait deux choses à la fois : elle génère les types de contenu,
ce qui ne demande aucun CMS, et elle exécute tous les loaders, ce qui en demande un. Il
n'existe aucun moyen de ne réclamer que la première.
flowchart LR check["astro check"] --> sync["astro sync"] sync --> types["Génération des types<br/>(sans CMS)"] sync --> loaders["Exécution des loaders<br/>(CMS requis)"] types --> tsc["Vérification de types"]
La CI détient donc maintenant de vraies informations d'identification en lecture. Le même mécanisme explique une gêne plus modeste : en développement, une modification du CMS exige un redémarrage, pas un rafraîchissement, puisque les loaders s'exécutent à la synchronisation.
TypeScript refuse de donner une signature d'index à une interface. Le parseData
d'Astro est typé TData extends Record<string, unknown>, et TypeScript accorde des
signatures d'index implicites aux types mappés et aux alias de types littéraux — mais
jamais à une interface, ni au travers d'un simple alias vers une interface. La moitié de
mes types de contenu sont des interfaces. La correction tentante est de réécrire chacun en
{ [K in keyof T]: T[K] }, ce qui fonctionne et ne perd rien. La bonne correction était de
cesser de laisser une contrainte générique dicter la forme de mes types de domaine :
retirer la contrainte de mes propres fabriques et convertir une seule fois, à la frontière.
astro:content est réservé au serveur, et c'est une fuite qui n'attend qu'un prétexte.
Ce dépôt en avait déjà connu une : un îlot de graphique importait un module qui atteignait
le client Strapi et, à travers lui, un module virtuel côté serveur ; l'îlot échouait à
s'hydrater avec une 500. Le commentaire qui consigne l'incident est toujours dans le
fichier. Cette migration a recréé le même danger par un chemin neuf, puisque chaque module
de données importe désormais astro:content.
Les imports de type seul sont effacés au build, donc sans danger — c'est la seule raison pour laquelle la frontière existante a tenu. C'est bien trop subtil pour être laissé à la mémoire : il y a maintenant un test qui parcourt le graphe d'imports depuis chaque point d'entrée client et échoue sur tout import à l'exécution d'un module réservé au serveur. Sa première version passait alors qu'une violation réelle se trouvait dans l'arbre, parce qu'elle ne vérifiait que les imports directs d'une seule extension de fichier. Un garde-fou qu'on n'a jamais vu échouer n'est pas un garde-fou.
Le résultat
Quatorze loaders, chacun s'exécutant exactement une fois par build ; 796 entrées de collection réparties sur quatre locales ; 136 pages. Le build n'est pas spectaculairement plus rapide — Strapi tourne sur la même machine et n'a jamais été le goulot d'étranglement — mais la forme est enfin juste, et la quantité de code qui n'existait que pour contourner l'ancienne est désormais nulle.