Astro v6 : quand Content Collections vident 312 balises title sans prévenir
Mercredi 14h20. Un éditeur tech français — 312 articles de blog, 40K sessions organiques mensuelles — pousse la migration Astro 5.7 vers 6.1. Le build passe. Lighthouse reste vert. Personne ne regarde le <title> des pages rendues. Dix-huit jours plus tard, Search Console affiche −14K clics sur la période. Le coupable : un refacto interne des Content Collections qui change la façon dont le frontmatter remonte dans les templates. Les titres sont vides. Googlebot indexe du néant.
Jeudi 9h12 — "On a un souci de trafic, non ?"
Le responsable éditorial ouvre Search Console le jeudi matin, neuf jours après le déploiement. La courbe de clics plonge depuis cinq jours. Pas un effondrement brutal — une descente progressive, −8 % par jour, le genre de signal qu'on confond avec de la saisonnalité.
Premier réflexe : vérifier si un core update Google est en cours. La réponse est oui — le May 2026 Core Update roule depuis quelques jours. L'équipe conclut trop vite : "C'est le core update, on attend." Le lead SEO acquiesce. Personne ne creuse davantage.
Quatre jours passent. Le trafic continue de baisser. Le lead SEO lance un crawl Screaming Frog sur l'ensemble du blog. 312 URLs. Le rapport sort en 47 secondes. Colonne Title 1 : 289 lignes affichent un titre vide ou le fallback <title>undefined</title>. 23 pages seulement gardent un titre correct — celles qui utilisent un layout différent, hérité de l'ancien code.
Le lead SEO envoie un message dans le canal Slack #seo-tech à 14h03 : "289 pages ont un title vide ou undefined. Depuis quand ?"
Le développeur front vérifie le commit history. La migration Astro v5 → v6 a été mergée le mercredi précédent. Le build CI était vert. Les tests end-to-end Playwright passaient — mais aucun ne vérifiait le contenu de la balise <title>.
L'hypothèse initiale du dev : "Le layout a dû casser, je regarde." Il ouvre le fichier [...slug].astro, le layout principal, le composant <BaseHead>. Tout semble en place. Le frontmatter YAML des fichiers .md contient bien un champ title. Le Zod schema le valide. Pourtant le HTML rendu affiche <title></title>.
À 15h20, le dev tape dans le terminal :
npx astro build && cat dist/blog/mon-article/index.html | grep '<title>'
Résultat :
<title></title>
Le frontmatter existe. Le schema passe. Le build compile. Mais le titre ne remonte pas dans le template. Le dev comprend que ce n'est pas un bug mineur. C'est une régression d'API silencieuse entre Astro 5 et Astro 6.
Le bug : Content Collections v2 et le nouveau shape de entry.data
Pour comprendre la casse, il faut remonter à ce qu'Astro v6 a changé dans les Content Collections.
L'API en Astro v5
En Astro 5.x, quand on récupérait une entrée de collection, la structure ressemblait à ceci :
// src/pages/blog/[...slug].astro — Astro 5.x
---
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<BlogLayout title={post.data.title} description={post.data.description}>
<Content />
</BlogLayout>
post.data.title fonctionnait. Le champ data était un objet plat contenant directement les propriétés du frontmatter, validées par le schéma Zod défini dans src/content/config.ts.
Ce qu'Astro v6 a changé
Astro v6 a introduit un refacto majeur des Content Collections sous le nom interne "Content Layer v2". L'objectif : supporter des sources de contenu distantes (CMS headless, API, bases de données) au même niveau que les fichiers Markdown locaux. Pour unifier l'API, la structure de retour de getCollection() a été modifiée.
Le changement critique : pour les collections basées sur des fichiers locaux, le frontmatter n'est plus directement sous entry.data. Il est désormais encapsulé sous entry.data.frontmatter quand la collection utilise le loader file ou glob (le défaut pour les fichiers .md et .mdx).
Voici la nouvelle config de collection en Astro v6 :
// src/content/config.ts — Astro 6.x
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
tags: z.array(z.string()).optional(),
}),
});
export const collections = { blog };
Le schéma Zod est identique. Il valide toujours les mêmes champs. Mais l'accès dans le template doit maintenant passer par entry.data.frontmatter.title au lieu de entry.data.title — dans certaines configurations du loader.
Le piège : le comportement exact dépend de la façon dont le loader glob résout les fichiers. Dans le cas de cette équipe, la migration vers le nouveau loader (obligatoire en v6, l'ancien système type: 'content' étant déprécié puis supprimé) a changé le shape de data sans qu'aucune erreur TypeScript ne remonte.
Pourquoi pas d'erreur TypeScript ? Parce que le type inféré par Zod restait correct au niveau du schéma. Le problème se situait au runtime : post.data.title retournait undefined car la valeur vivait désormais un niveau plus bas. Mais undefined en TypeScript, dans un template Astro, ne produit pas d'erreur de compilation. Il produit une string vide dans le HTML.
Ce que voit le développeur vs ce que voit Googlebot
Le développeur ouvre la page dans le navigateur. Le titre s'affiche dans le <h1> — parce que le composant <Content /> rend le Markdown, et le # Titre du fichier .md génère un <h1> indépendamment du frontmatter. L'illusion est parfaite.
Googlebot, lui, lit le <head>. Voici ce qu'il trouvait :
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title></title>
<meta name="description" content="" />
<link rel="canonical" href="https://example.com/blog/mon-article" />
<meta property="og:title" content="" />
<meta property="og:description" content="" />
</head>
<title> vide. <meta name="description"> vide. og:title vide. Quatre balises critiques en SEO, toutes dépendantes de post.data.title et post.data.description, toutes résolues à undefined, toutes rendues comme chaînes vides.
Le composant <BaseHead> incriminé :
// src/components/BaseHead.astro
---
const { title, description } = Astro.props;
---
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
Le composant est sain. C'est l'amont qui envoie undefined. Et Astro, contrairement à React qui rendrait littéralement la string "undefined", rend une string vide quand la valeur est undefined dans une expression de template. Comportement logique côté framework, catastrophique côté SEO.
Pourquoi les tests n'ont rien vu
L'équipe avait des tests Playwright. Mais les assertions portaient sur :
- Le statut HTTP (200)
- La présence du
<h1>(qui venait du Markdown, pas du frontmatter) - L'absence d'erreur console
Aucun test ne vérifiait document.title ou le contenu de <meta name="description">. Un oubli classique. Le <h1> était correct, le navigateur affichait la page normalement, et le build ne crashait pas.
Pour confirmer le diagnostic, le dev a utilisé l'outil d'inspection d'URL de Search Console sur une page impactée. Le rendu HTML montrait le <title> vide. L'inspection confirmait aussi que Google avait re-crawlé la page trois jours après le déploiement et avait indexé la version sans titre.
Une vérification complémentaire avec curl :
curl -s https://example.com/blog/optimiser-images-webp | head -30
Sortie confirmant le <title></title> vide dans le HTML statique servi. Pas besoin de JavaScript rendering — Astro génère du HTML statique, ce qui veut dire que le bug était visible dès le premier octet servi. Aucune excuse côté rendu client.
Le fix : 47 minutes pour patcher, 19 jours pour récupérer
Patch 1 — Corriger l'accès au frontmatter
La première option, la plus propre, consistait à adapter le template à la nouvelle API :
// src/pages/blog/[...slug].astro — Astro 6.x corrigé
---
import { getCollection } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
// Astro 6 : le frontmatter peut être sous data directement
// OU sous data.frontmatter selon le loader. On normalise.
const frontmatter = post.data.frontmatter ?? post.data;
const title = frontmatter.title;
const description = frontmatter.description;
---
<BlogLayout title={title} description={description}>
<Content />
</BlogLayout>
Le pattern post.data.frontmatter ?? post.data gère les deux cas : la nouvelle structure et l'ancienne, au cas où certaines collections n'auraient pas encore migré vers le nouveau loader.
Note importante : en Astro v6, post.slug est remplacé par post.id pour les chemins statiques. Autre changement silencieux qui peut casser les URLs si non anticipé. Dans le cas de cette équipe, les slugs étaient déjà basés sur les noms de fichiers, donc post.id produisait le même résultat. Un coup de chance.
Patch 2 — Ajouter un guard dans BaseHead
Pour éviter que le problème ne se reproduise avec un autre champ, l'équipe a ajouté un fallback défensif :
// src/components/BaseHead.astro — version durcie
---
const { title, description } = Astro.props;
const safeTitle = title || 'Blog — Example.com';
const safeDescription = description || 'Découvrez nos articles techniques.';
if (!title) {
console.warn(`[SEO] Missing title for page: ${Astro.url.pathname}`);
}
---
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{safeTitle}</title>
<meta name="description" content={safeDescription} />
<meta property="og:title" content={safeTitle} />
<meta property="og:description" content={safeDescription} />
Le console.warn au build permet de détecter les pages sans titre dans les logs CI. Pas un remplacement pour un vrai test, mais un filet de sécurité supplémentaire.
Patch 3 — Ajouter un test Playwright sur les metas
// tests/seo-meta.spec.ts
import { test, expect } from '@playwright/test';
const sampleUrls = [
'/blog/optimiser-images-webp',
'/blog/guide-typescript-2026',
'/blog/astro-vs-next-comparatif',
];
for (const url of sampleUrls) {
test(`Page ${url} has a non-empty title and meta description`, async ({ page }) => {
await page.goto(url);
const title = await page.title();
expect(title).not.toBe('');
expect(title).not.toBe('undefined');
expect(title.length).toBeGreaterThan(10);
const description = await page
.locator('meta[name="description"]')
.getAttribute('content');
expect(description).not.toBe('');
expect(description).not.toBe('undefined');
expect(description!.length).toBeGreaterThan(20);
});
}
Ce test aurait détecté le bug avant le merge. L'équipe l'a intégré dans la CI avec une règle simple : le pipeline bloque si une seule page de l'échantillon a un titre vide.
Déploiement et invalidation
Le fix a été déployé 47 minutes après le diagnostic confirmé. Le site étant hébergé sur Cloudflare Pages, le build et le déploiement ont pris 2 minutes 14 secondes. L'équipe a purgé le cache Cloudflare manuellement par précaution, même si les pages statiques d'Astro n'utilisent pas de cache edge agressif par défaut.
Ensuite, soumission en masse des 289 URLs impactées via l'API d'indexation de Search Console. Pas via l'interface — 289 pages une par une, ce n'est pas viable. Un script Python avec la librairie google-auth et l'API Indexing a fait le travail en 12 secondes.
Timeline de récupération
- J+0 (fix déployé) : HTML corrigé, titres présents.
- J+2 : Search Console montre que 78 pages ont été re-crawlées.
- J+5 : 210 pages re-crawlées. Les impressions commencent à remonter.
- J+9 : 289/289 pages re-crawlées avec le bon titre.
- J+12 : les clics reviennent à 70 % du niveau pré-incident.
- J+19 : retour à la normale. 39,2K clics sur les 28 derniers jours, contre 40,1K sur la période de référence. La différence de 2 % est dans la marge du core update en cours.
Le facteur aggravant : le core update de mai 2026 tournait en parallèle. Impossible de savoir avec certitude si la récupération aurait été plus rapide sans. Mais les données Search Console montrent clairement que la chute de clics a commencé trois jours après le déploiement du code cassé — pas au début du core update. La corrélation temporelle est sans ambiguïté.
L'équipe a vérifié manuellement via Screaming Frog après le fix : 312/312 pages avec un <title> non vide, longueur moyenne de 52 caractères, aucun doublon. Un crawl propre.
Ce qu'on en retient
Le frontmatter était là. Le schéma Zod était valide. Le build passait. Les tests aussi. Et pourtant, 289 pages ont tourné dix-huit jours avec un <title> vide — parce que le chemin d'accès à une propriété a changé d'un niveau de profondeur entre deux versions majeures.
Les migrations de framework ne cassent pas toujours ce qu'on surveille. Elles cassent ce qu'on suppose stable. Le <title> est l'élément SEO le plus basique qui existe. C'est aussi celui que personne ne teste explicitement, parce que "ça a toujours marché."
Trois règles à graver : tester les balises <head> dans la CI, pas seulement le rendu visible. Lire les changelogs des loaders de contenu, pas seulement ceux du framework. Et monitorer le HTML réellement servi après chaque déploiement — un outil comme Seogard détecte une divergence de <title> entre deux crawls en quelques minutes, pas en dix-huit jours.
Les régressions SEO silencieuses ne sont jamais spectaculaires. C'est pour ça qu'elles font autant de dégâts. Le prochain champ qui passera de data.x à data.nested.x ne préviendra pas non plus.