80 articles sans title : quand Astro Content Collections cassent en silence
Jeudi 14h20. L'équipe technique d'une agence SaaS B2B pousse un upgrade d'Astro 4.x vers 5.x sur un blog de 80 articles. Le build passe. Le déploiement passe. Lighthouse affiche un 98 en performance. Le designer valide le rendu. Personne ne regarde le <head>. Dix-huit jours plus tard, Search Console envoie un mail : "Amélioration des titres — 80 pages avec un titre manquant". Le trafic organique du blog a déjà perdu 34 % de ses clics.
Lundi matin, T+18 jours — L'alerte qui réveille
Le lead SEO ouvre Search Console à 8h52. Le rapport "Améliorations" affiche un bandeau jaune inédit. Il clique. 80 URLs du blog sont signalées avec le problème "Balise title manquante ou vide". Il rafraîchit. Même résultat.
Premier réflexe : ouvrir une page dans le navigateur. Le titre s'affiche dans l'onglet. Tout semble normal. Il inspecte le DOM avec Chrome DevTools. Le <title> est bien là, injecté côté client par un composant JavaScript. Mais DevTools montre le DOM après hydratation. Pas le HTML source.
Il passe à curl :
curl -s https://blog.example.com/posts/guide-onboarding-2026 | grep -i '<title>'
Résultat :
<title></title>
Vide. Quatre caractères. Pas d'erreur. Pas de 500. Juste un <title> creux servi à chaque crawler de la planète depuis dix-huit jours.
Il lance un crawl Screaming Frog sur les 80 URLs du répertoire /posts/. Résultat : 80 pages avec <title> vide. 80 pages avec <meta name="description"> vide. 80 pages avec <meta property="og:title"> absent. Le trifecta.
Le lead SEO remonte dans GA4. Le segment "Organic Search" sur le scope /posts/ montre une chute progressive. Pas un cliff — une glissade. −12 % la première semaine. −22 % la deuxième. −34 % au moment de l'alerte. Environ 4 200 clics perdus sur la période. Le blog génère 40 % des MQLs de l'entreprise. La réunion de crise est convoquée à 9h15.
L'hypothèse initiale du dev lead : "C'est peut-être un problème de cache CDN." L'équipe purge le cache Cloudflare. Nouveau curl. Toujours vide. Deuxième hypothèse : "Le composant <head> a un bug d'import." Le dev ouvre le layout principal, vérifie les imports. Tout compile. Troisième hypothèse : "C'est un problème de données — le CMS ne renvoie plus les titres." L'équipe vérifie les fichiers Markdown sources. Les frontmatters sont intacts. Chaque fichier contient bien un champ title.
C'est à 9h47 que le dev frontend junior lâche la phrase qui déclenche le vrai diagnostic : "On a changé la façon de récupérer les articles quand on est passé à Astro 5, non ?"
Le bug : Content Collections v2 et le fantôme de entry.data
Le blog utilise Astro avec des Content Collections. Chaque article est un fichier .md dans src/content/posts/. Le frontmatter ressemble à ceci :
---
title: "Guide complet de l'onboarding client en 2026"
description: "Les 7 étapes clés pour un onboarding qui réduit le churn de 40%."
pubDate: 2026-05-12
author: "Marie Dupont"
tags: ["onboarding", "saas", "product"]
---
Le contenu de l'article commence ici...
Avant l'upgrade, en Astro 4.x, le schéma de collection dans src/content/config.ts était déclaré ainsi :
// src/content/config.ts — Astro 4.x
import { defineCollection, z } from 'astro:content';
const posts = defineCollection({
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.date(),
author: z.string(),
tags: z.array(z.string()),
}),
});
export const collections = { posts };
Et le layout de page article (src/layouts/PostLayout.astro) récupérait les données comme ceci :
---
// src/layouts/PostLayout.astro — Astro 4.x
const { frontmatter } = Astro.props;
---
<html>
<head>
<title>{frontmatter.title}</title>
<meta name="description" content={frontmatter.description} />
<meta property="og:title" content={frontmatter.title} />
</head>
<body>
<h1>{frontmatter.title}</h1>
<slot />
</body>
</html>
Ce pattern fonctionnait parfaitement. Quand Astro rendait une page .md, il passait le frontmatter directement via Astro.props.frontmatter au layout déclaré dans le fichier Markdown.
Lors de l'upgrade vers Astro 5.x, l'équipe a migré vers la nouvelle API Content Collections. La page dynamique src/pages/posts/[...slug].astro a été refactorisée :
---
// src/pages/posts/[...slug].astro — Astro 5.x (après refacto)
import { getCollection } from 'astro:content';
import PostLayout from '../../layouts/PostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<PostLayout>
<Content />
</PostLayout>
Le problème est ici, en trois lignes.
L'ancien pattern passait frontmatter comme prop au layout. Le nouveau pattern passe entry entier comme prop — mais ne transmet rien au layout. Le composant PostLayout reçoit un Astro.props qui contient... un slot. Pas de frontmatter. Pas de entry.data. Rien.
Le layout cherche Astro.props.frontmatter.title. Il obtient undefined. Astro ne lève aucune erreur. En mode SSG, undefined dans une expression {frontmatter.title} produit une chaîne vide. Le build passe. TypeScript, si configuré en mode lax (ce qui était le cas ici), ne signale rien.
Le HTML rendu côté serveur — celui que Googlebot reçoit — ressemble à ceci :
<html>
<head>
<title></title>
<meta name="description" content="" />
<!-- og:title absent car content="" est filtré -->
</head>
<body>
<h1></h1>
<article>
<p>Le contenu de l'article commence ici...</p>
</article>
</body>
</html>
Le <h1> est également vide. Le contenu brut de l'article est bien rendu (via <Content />), mais toute la couche de métadonnées a disparu.
Ce que voit le développeur dans le navigateur est différent, et c'est le piège. Le design system de l'équipe utilise un composant React hydraté côté client qui récupère le titre depuis le premier <h1> ou depuis un <script type="application/json"> embarqué. Ce composant injecte le titre dans l'onglet du navigateur via document.title. L'inspection DOM dans DevTools montre donc un <title> rempli — mais uniquement après exécution JavaScript.
Googlebot, en 2026, exécute JavaScript. Mais il ne le fait pas systématiquement, et surtout pas immédiatement. Le rendu différé de Google signifie que la première passe d'indexation se base sur le HTML brut. Si le <title> est vide dans le HTML source, Google enregistre "titre manquant" avant même d'envisager un rendu JS.
Pour 80 articles, Google a donc généré ses propres titres — souvent la première phrase du contenu, ou le nom de domaine. Résultat : des SERP avec des snippets incohérents, des CTR en chute libre, et un signal clair de dégradation qualitative.
Le diagnostic complet est confirmé par un test avec l'outil "Inspection d'URL" de Search Console. L'onglet "HTML rendu" montre le <title> vide sur le HTML initial. L'onglet "Capture d'écran" montre le titre visible (grâce au JS client). Deux réalités parallèles.
Pour comprendre pourquoi les tests unitaires et le CI n'ont rien détecté, il faut regarder la pipeline. L'équipe testait le build Astro avec un simple astro build && echo "OK". Le build ne crashe pas quand une expression template retourne undefined. Ils avaient un test Playwright qui vérifiait la présence d'un <h1> visible — mais le test s'exécute dans un vrai navigateur avec JS, donc il voit le titre injecté côté client. Zéro test sur le HTML statique brut. C'est un pattern que l'on retrouve fréquemment, et que l'on a déjà documenté lors d'une migration Astro où le mapping frontmatter title avait cassé de la même manière.
Le fix : reconnecter entry.data au layout
Le correctif tient en deux modifications. La première, dans la page dynamique, consiste à passer explicitement les données du frontmatter au layout :
---
// src/pages/posts/[...slug].astro — CORRIGÉ
import { getCollection } from 'astro:content';
import PostLayout from '../../layouts/PostLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
const { entry } = Astro.props;
const { Content } = await entry.render();
---
<PostLayout frontmatter={entry.data}>
<Content />
</PostLayout>
La clé : entry.data contient exactement le même objet que l'ancien frontmatter. La seule différence est qu'il faut le passer explicitement comme prop.
La deuxième modification, dans le layout, consiste à typer la prop pour éviter toute régression future :
---
// src/layouts/PostLayout.astro — CORRIGÉ avec typage
interface Props {
frontmatter: {
title: string;
description: string;
pubDate: Date;
author: string;
tags: string[];
};
}
const { frontmatter } = Astro.props;
---
<html>
<head>
<title>{frontmatter.title}</title>
<meta name="description" content={frontmatter.description} />
<meta property="og:title" content={frontmatter.title} />
</head>
<body>
<h1>{frontmatter.title}</h1>
<slot />
</body>
</html>
Avec ce typage, si frontmatter est undefined ou si title manque, le build Astro échoue en mode strict (astro check). L'erreur est détectée au CI, pas en production.
L'équipe a ajouté un troisième filet de sécurité : un test dans la pipeline CI qui vérifie le HTML statique généré, sans navigateur :
# scripts/check-titles.sh
astro build
MISSING=0
for file in dist/posts/*/index.html; do
TITLE=$(grep -oP '(?<=<title>).*(?=</title>)' "$file")
if [ -z "$TITLE" ]; then
echo "ERREUR: title vide dans $file"
MISSING=$((MISSING + 1))
fi
done
if [ "$MISSING" -gt 0 ]; then
echo "$MISSING page(s) avec title vide. Build rejeté."
exit 1
fi
echo "Toutes les pages ont un title. OK."
Ce script parcourt les fichiers HTML générés par astro build et vérifie que chaque <title> contient du texte. Il s'exécute après le build, avant le déploiement. Coût : 3 secondes sur une pipeline de 45 secondes.
Le déploiement du fix a été effectué le jour même, à 11h30. Le cache Cloudflare a été purgé dans la foulée. L'équipe a ensuite soumis les 80 URLs à la réindexation via Search Console (par lots de 10, la limite quotidienne étant ce qu'elle est).
La récupération a pris du temps. Voici la chronologie observée :
- J+2 : Google a recrawlé 31 des 80 pages. Les titles corrects apparaissent dans le cache Google.
- J+5 : 68 pages recrawlées. Les snippets commencent à afficher les vrais titres dans les SERP.
- J+12 : 80/80 pages recrawlées. Le trafic organique remonte à −15 % par rapport au niveau pré-incident.
- J+21 : Le trafic organique retrouve son niveau initial. Certaines pages ont même gagné des positions, probablement parce que les titres corrects matchent mieux l'intention de recherche que les titres auto-générés par Google.
Au total : 18 jours de dégradation silencieuse, 3 semaines de récupération, environ 8 400 clics perdus cumulés.
L'équipe a mis en place deux garde-fous supplémentaires. Le premier : activer astro check en mode strict dans le CI, avec le flag --tsconfig tsconfig.json configuré en "strict": true. Le second : un crawl Screaming Frog hebdomadaire automatisé qui alerte sur tout <title> vide ou dupliqué, configuré via l'API Screaming Frog.
Ce type de régression silencieuse est spécialement vicieux sur les stacks SSG. Le build passe, le site s'affiche, les tests visuels ne détectent rien. Un incident similaire a été documenté dans un contexte différent lors d'une refonte header où le H1 a été remplacé par un div, et lors d'une migration Gatsby vers Astro où le RSS feed est resté orphelin pendant 6 semaines. Le pattern est toujours le même : un changement structurel dans le wiring des composants, aucune erreur visible, et une dégradation SEO progressive que personne ne surveille.
Ce qu'on en retient
Le frontmatter ne se transmet pas par magie. Quand un framework change son API de data-fetching — même subtilement — le contrat entre la source de données et le template se brise sans bruit. Les tests navigateur ne protègent pas contre les régressions de HTML statique. Seul un contrôle sur le HTML brut généré, exécuté à chaque build, peut détecter un <title> vide avant que Googlebot ne le fasse à notre place.
Un monitoring continu comme Seogard détecte ce type de divergence entre HTML source et DOM hydraté en quelques minutes — pas en dix-huit jours. Mais même sans outil externe, un script shell de 12 lignes dans le CI suffit à éviter 8 400 clics perdus. Il n'y a aucune excuse pour ne pas l'avoir.