Un CMS pour Astro sans renoncer au statique
Comment laisser un client publier sur un site Astro sans passer en rendu serveur, sans lui donner accès au dépôt, et sans qu'une panne du CMS ne mette le site à terre.
Un site Astro qui sort en statique se déploie sur un CDN, coûte presque rien à héberger et tient une pointe de trafic sans rien faire. Le jour où le client veut changer un tarif, tout se complique : soit on lui ouvre le dépôt Git, soit on passe le site en rendu serveur, soit on reçoit le mail « tu peux me changer ça ? » pour la troisième fois du mois.
Aucune de ces trois options n’est bonne. Voici comment on a tranché.
Pourquoi les solutions habituelles coincent
Les CMS qui écrivent dans Git (Decap, Tina). L’éditeur commite dans le dépôt. C’est élégant techniquement, mais le client hérite d’un compte GitHub et d’une mécanique qu’il ne comprend pas, où la moindre erreur de fusion devient son problème. Surtout, le contenu et le code partagent le même historique : rétablir un texte oblige à toucher au dépôt.
Les headless classiques (Strapi, Sanity, Prismic). De bons produits, mais l’entrée payante se situe entre 10 et 18 $ par mois et par projet. Sur un parc de vingt sites vitrine facturés 40 € de maintenance mensuelle, la marge disparaît. Les langues supplémentaires sont presque toujours un palier tarifaire de plus.
Rendu serveur. On perd exactement ce pour quoi on avait choisi Astro : le site devient une application à surveiller, avec un temps de réponse, un cache à réfléchir et une facture d’hébergement qui suit le trafic.
Ce qu’on fait à la place
Le principe tient en une phrase : le CMS ne sert jamais le site, il sert le build.
- L’éditeur travaille dans une interface web, sur son téléphone s’il le veut.
- Quand il clique sur Publier, l’API fige l’état du contenu dans un fichier JSON immuable : un instantané, poussé sur un CDN.
- L’API déclenche ensuite le build chez l’hébergeur du site (Vercel, Netlify, Cloudflare, GitHub Actions ou un serveur à soi).
- Le build lit l’instantané et sort le site statique, comme d’habitude.
Le contenu voyage donc du CMS vers le site au moment du build, et jamais à la requête d’un visiteur. Le site final n’a aucune dépendance vers le CMS : il ne l’appelle pas, ne le connaît pas, et n’a pas besoin qu’il soit allumé.
La conséquence qui compte
Si le CMS tombe, les sites publiés restent en ligne. Ils sont statiques, ils sont sur un CDN : il n’y a rien qui puisse tomber.
Mieux : l’instantané étant sur un CDN et non dans l’API, un build lancé pendant une panne du CMS aboutit quand même. C’est un test qu’on fait volontairement, en éteignant l’API pour vérifier qu’un déploiement passe encore.
Toute la différence est là : entre un CMS dont le site dépend, et un CMS dont le site se sert.
Ce que ça change à votre code Astro
Rien, et c’est justement le point à souligner.
Le contenu arrive par un loader Content Layer standard, donc getCollection() rend des entrées typées et render() les affiche. Vos composants de page ne savent pas et ne se soucient pas d’où vient le contenu. Remplacer un dossier de markdown par un CMS est un changement dans content.config.ts et rien d’autre :
import { defineCollection } from 'astro:content';
import { menestrelLoader } from '@menestrel/astro';
export const collections = {
services: defineCollection({ loader: menestrelLoader({ collection: 'services' }) }),
};
Le loader déduit le schéma de votre modèle de contenu, donc vous ne le redites pas en Zod et les deux ne peuvent pas diverger.
Les images sont le seul endroit où les gabarits changent en général, et plutôt en mieux : au lieu d’un chemin vous recevez un objet portant dimensions, texte alternatif et point d’intérêt, ce qu’il faut pour éviter les sauts de mise en page et recadrer correctement sur mobile.
Côté développeur : le schéma reste dans le code
Le modèle de contenu est déclaré dans un fichier TypeScript du projet, pas cliqué dans une interface :
import { collection, defineConfig, fields } from '@menestrel/fields';
export default defineConfig({
project: 'atelier-morel',
locales: { default: 'fr', others: ['en'] },
collections: {
services: collection({
label: { fr: 'Prestations', en: 'Services' },
slugFrom: 'title',
fields: {
title: fields.text({ required: true, localized: true }),
summary: fields.textarea({ localized: true }),
price: fields.number({ unit: '€' }),
photo: fields.image(),
},
}),
},
});
Une commande synchronise ce schéma vers le serveur, qui génère les formulaires d’édition correspondants. Le schéma vit donc dans la revue de code, dans les branches et dans l’historique Git, comme le reste du projet. Ce qui n’y vit pas, c’est le contenu : le texte du client n’a rien à faire dans un dépôt.
Côté rendu, le contenu arrive par un loader Content Layer standard, donc getCollection() et le typage fonctionnent comme avec n’importe quelle source Astro.
Ce qu’est réellement un instantané
Le mot « instantané » porte beaucoup de sens plus haut, voici donc ce qu’il recouvre concrètement.
Quand un éditeur publie, l’API sérialise l’état publié de chaque entrée du projet dans un unique document JSON, lui donne un identifiant qui ne change jamais, et l’envoie sur un stockage objet derrière un CDN. Rien n’y est modifiable ensuite. Une publication ultérieure produit un nouvel instantané avec un nouvel identifiant ; l’ancien reste exactement tel qu’il était jusqu’à son expiration.
Trois propriétés en découlent, et ce sont elles qui justifient l’architecture.
Une construction correspond à exactement un état de contenu. Sans cela, une construction qui démarre à 14 h 31 et finit à 14 h 33 contiendra peut-être une modification faite à 14 h 32, ou pas, selon les caches et le hasard. Cette famille de bugs est pénible à diagnostiquer parce que tout signale un succès.
Revenir en arrière consiste à sélectionner, pas à reconstituer. Chaque publication est déjà un point de restauration. Revenir à ce matin, c’est pointer une construction sur un identifiant antérieur, pas remettre le contenu à la main de mémoire.
Le CMS n’est pas sur le chemin. La construction récupère un fichier statique depuis un CDN. Notre API peut être éteinte, en maintenance ou en plein mauvais après-midi, le déploiement d’un client aboutit quand même. Nous le vérifions volontairement : on coupe l’API, on déclenche une construction, on confirme qu’elle passe au vert.
Le coût, c’est le stockage, d’où l’expiration des instantanés : sept jours sur le plan gratuit, quatre-vingt-dix jours sur Site, un an sur Studio. Au-delà, revenir en arrière veut à nouveau dire éditer à la main.
Où vit le schéma, et où il ne vit pas
Une distinction de plus qui façonne tout le reste : le modèle est du code, le contenu non.
Le modèle de contenu est une décision technique avec des conséquences sur vos gabarits. Il appartient à votre dépôt, à la revue de code, aux branches, à l’historique. Une demande de fusion qui ajoute un champ et le composant qui le consomme est un seul changement cohérent.
Les mots du client ne sont pas une décision technique. Ils changent à son rythme, ils sont écrits par des gens qui ne verront jamais un dépôt, et leur historique devrait se lire comme une liste de publications plutôt que comme des commits entremêlés à vos remaniements.
Il y a aussi un argument pratique : les photos. Du contenu dans un dépôt veut dire des images dans un dépôt, et un site vitrine peut accumuler plusieurs centaines de mégaoctets d’images mortes en un an, téléchargées par chaque clone, indéfiniment.
L’obligation que cela crée est un export qui fonctionne réellement. Le nôtre est une commande, sur tous les plans y compris le gratuit, qui produit du markdown et du JSON au format des content collections : exactement la forme que vous auriez eue avec des fichiers plats.
Ce que ça ne fait pas
Par honnêteté, les limites du modèle :
- La publication n’est pas instantanée. Il y a un build entre le clic et la mise en ligne, en général une à trois minutes. On l’assume et on l’affiche : l’éditeur voit la progression et sait quand son changement est en ligne.
- Ce n’est pas fait pour du contenu qui change toutes les minutes. Un site d’actualité en flux continu veut du rendu serveur, pas ça.
- Ce n’est pas un constructeur de pages. L’éditeur remplit des champs définis par le développeur, il ne déplace pas des blocs sur une grille.
Ces trois limites sont des choix, pas des fonctionnalités en retard. Elles sont ce qui permet de garder le site statique, l’hébergement à quelques centimes et le modèle mental simple.
Pour essayer
La documentation détaille l’installation, et le starter met un projet debout en une commande :
npm create menestrel@latest
Le plan gratuit couvre un site complet, deux langues comprises, sans carte bancaire.
Si vous avez déjà un site Astro avec des collections markdown locales, menestrel import les lit directement, images comprises, et construit le modèle de contenu à partir de ce qu’il trouve. Sur un site vitrine, c’est en général moins de trente minutes.
Et si vous préférez voir comment ça se compare à l’outil que vous utilisez déjà, les comparatifs passent en revue Contentful, Strapi, Sanity, Storyblok, Directus et la famille des CMS git, chacun avec les cas où il est le meilleur choix.