Astro View Transitions : quand les meta head restent figées après chaque navigation
Mercredi 14h. Un développeur front active <ViewTransitions /> sur un site éditorial Astro 4.x de 1 200 pages. Les animations sont fluides, le client valide. Le déploiement part sur Vercel à 15h12. Personne ne regarde le <head> des pages internes. Dix-huit jours plus tard, Search Console affiche −38 000 clics sur les requêtes de longue traîne. Toutes les pages profondes portent le <title> et la <meta description> de la homepage.
T+18 jours — Lundi 9h03 : le graphique qui décroche
Le responsable SEO ouvre Search Console pour le reporting mensuel. Le graphique de performances montre un décrochage net, pile le 14 du mois précédent. −38 400 clics sur 18 jours. Les impressions tiennent — les positions, non.
Il filtre par page. La homepage est stable. Les catégories aussi. Mais les 847 pages articles, fiches auteur et pages tag affichent toutes le même titre dans le rapport « Apparence dans les résultats » : "Accueil — Le Mag Outdoor".
Premier réflexe : vérifier la Search Console pour un éventuel problème d'indexation. Aucune erreur 4xx, aucun noindex détecté. Les pages sont indexées, crawlées, servies en 200.
Il ouvre un article dans Chrome. Inspecte le <head>. Le <title> est correct : « Les 10 meilleurs sentiers de randonnée en Chartreuse — Le Mag Outdoor ». Il rafraîchit — toujours bon. Il partage l'URL dans Slack : « Je ne vois rien d'anormal côté head. »
Le développeur front jette un œil. Il reproduit le parcours utilisateur normal : il arrive sur la homepage, clique sur un lien catégorie, puis sur un article. Il ouvre les DevTools et regarde l'onglet Elements. Le <title> dans le DOM indique : "Accueil — Le Mag Outdoor". Il a navigué vers une page article, l'animation de transition s'est jouée, le contenu a changé — mais le <head> n'a pas bougé.
Retour dans Search Console. Outil d'inspection d'URL sur une fiche article. Google montre le HTML rendu : le <title> est correct. La page servie en SSR est bonne. Mais le développeur réalise que si Googlebot suit un lien interne via le mécanisme SPA des View Transitions — ce qu'il fait de plus en plus — il voit potentiellement le <head> de la page précédente.
L'hypothèse Googlebot-MPA est vite écartée : les logs serveur montrent que Googlebot a bien crawlé chaque URL individuellement (requêtes HTTP distinctes). Le SSR renvoie les bonnes meta. Le problème n'est pas côté Googlebot en crawl initial.
C'est côté rendu JavaScript post-navigation que ça casse. Et côté utilisateurs qui partagent des URLs après navigation SPA — les previews OpenGraph affichent le titre de la homepage. Les équipes social media confirment : les partages sur LinkedIn et Slack depuis deux semaines affichent tous « Accueil — Le Mag Outdoor ».
Le périmètre s'étend : ce n'est pas seulement un problème SEO. C'est un problème de métadonnées globales sur toute navigation client-side.
Le bug : View Transitions intercepte la navigation sans propager le head
Comment fonctionnent les View Transitions dans Astro
Astro, par défaut, fonctionne en MPA (Multi-Page Application). Chaque clic sur un lien déclenche une navigation complète : requête HTTP, nouveau document HTML, nouveau <head>. Les balises <title>, <meta>, <link rel="canonical"> sont servies par le SSR à chaque page load.
Quand on ajoute <ViewTransitions /> dans le layout, Astro bascule en mode SPA-like. Le composant intercepte les clics sur les liens internes, fetch le HTML de la page cible via fetch(), et swap le contenu du <body> avec une animation CSS. Le résultat : des transitions fluides, sans full page reload.
Voici le layout type qui active le mécanisme :
---
// src/layouts/BaseLayout.astro
import { ViewTransitions } from 'astro:transitions';
interface Props {
title: string;
description: string;
canonical: string;
}
const { title, description, canonical } = Astro.props;
---
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<ViewTransitions />
</head>
<body>
<slot />
</body>
</html>
Et une page article typique :
---
// src/pages/articles/[slug].astro
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getEntry } from 'astro:content';
const { slug } = Astro.params;
const article = await getEntry('articles', slug);
const { title, description } = article.data;
const canonical = `https://lemag-outdoor.fr/articles/${slug}`;
---
<BaseLayout title={title} description={description} canonical={canonical}>
<article>
<h1>{title}</h1>
<p>{article.body}</p>
</article>
</BaseLayout>
En SSR pur (navigation directe vers l'URL), tout fonctionne. Astro render le template complet, <head> inclus. Le HTML reçu par le navigateur (ou par Googlebot) contient les bonnes meta.
Ce qui casse en navigation client-side
Le problème survient quand Astro effectue le swap SPA. Dans les versions Astro 4.0 à 4.8, le comportement par défaut de <ViewTransitions /> est le suivant :
- L'utilisateur clique sur un lien interne.
- Le composant intercepte le clic, empêche la navigation native.
- Il
fetch()le HTML de la page cible. - Il extrait le contenu du
<body>de la réponse. - Il anime la transition et remplace le
<body>du document courant.
L'étape clé manquante : le <head> du document courant n'est pas systématiquement mis à jour. Astro est censé fusionner le <head> de la page cible avec le <head> courant. Mais dans certaines configurations — notamment quand des scripts tiers ou des composants client injectent des éléments dans le head au runtime — le swap du head échoue silencieusement.
Le résultat visible dans le DOM après navigation SPA :
<!-- L'utilisateur est sur /articles/sentiers-chartreuse -->
<!-- Mais le <head> affiche toujours : -->
<head>
<title>Accueil — Le Mag Outdoor</title>
<meta name="description" content="Le magazine outdoor pour les passionnés de randonnée et d'alpinisme." />
<link rel="canonical" href="https://lemag-outdoor.fr/" />
<meta property="og:title" content="Accueil — Le Mag Outdoor" />
<!-- ... -->
</head>
Pourquoi le bug est passé inaperçu
L'équipe utilise un composant <HeadSEO /> custom qui injecte un script inline pour mettre à jour document.title côté client. Ce script fonctionne sur le premier page load, mais ne se ré-exécute pas après un swap View Transitions.
---
// src/components/HeadSEO.astro
const { title } = Astro.props;
---
<script define:vars={{ title }}>
document.title = title;
</script>
Ce script s'exécute une seule fois, au chargement initial. Après une navigation View Transitions, le nouveau body est injecté, mais les scripts define:vars du head ne sont pas ré-exécutés. Le document.title reste sur la valeur de la page d'entrée.
La divergence développeur vs Googlebot
Le développeur teste toujours en accès direct (il tape l'URL, il recharge la page). Il voit le bon <title>.
L'utilisateur réel arrive sur la homepage, navigue en mode SPA, et voit le mauvais <title> sur les pages internes.
Googlebot, dans son crawl primaire, accède à chaque URL individuellement — il voit les bonnes meta. Mais quand il rend la page avec son moteur Chrome headless et simule des interactions (ce qu'il fait de plus en plus, selon la documentation Google), il peut observer le DOM post-navigation et constater la divergence.
Le diagnostic est confirmé avec une commande curl comparée au DOM live :
# SSR direct — OK
curl -s https://lemag-outdoor.fr/articles/sentiers-chartreuse \
| grep -E '<title>|<meta name="description"|<link rel="canonical"'
# Résultat :
# <title>Les 10 meilleurs sentiers de randonnée en Chartreuse — Le Mag Outdoor</title>
# <meta name="description" content="Découvrez notre sélection des plus beaux sentiers..." />
# <link rel="canonical" href="https://lemag-outdoor.fr/articles/sentiers-chartreuse" />
Puis dans Chrome DevTools, après navigation SPA depuis la homepage :
// Console DevTools après navigation View Transitions
console.log(document.title);
// "Accueil — Le Mag Outdoor"
console.log(document.querySelector('meta[name="description"]')?.content);
// "Le magazine outdoor pour les passionnés de randonnée et d'alpinisme."
console.log(document.querySelector('link[rel="canonical"]')?.href);
// "https://lemag-outdoor.fr/"
La preuve est là. Le DOM live ment. Le serveur dit vrai, mais le client ment — et tout service qui parse le DOM post-navigation (previews sociales, extensions SEO, outils de monitoring client-side) voit les meta de la page d'entrée.
Un audit Screaming Frog en mode « JavaScript rendering » sur les 1 200 URLs confirme : en crawl direct, 100% des meta sont correctes. Mais un crawl en mode « Suivre les liens internes avec JS activé » montre que 73% des pages visitées via navigation interne héritent du <head> de la page d'entrée du crawl.
Le cas aggravant : les canonicals
Le plus dangereux n'est pas le <title>. C'est le <link rel="canonical">. Si Google visite une page article et voit un canonical pointant vers la homepage, il peut décider que la page article est un doublon de la homepage. Ce scénario est similaire aux problèmes de canonicals mal propagés après migration — sauf qu'ici, il n'y a pas de migration. Juste un composant de transition qui casse la chaîne de meta.
Le fix : forcer le head swap et écouter les lifecycle events
Étape 1 — Supprimer le composant HeadSEO custom
Le script define:vars qui force document.title est un workaround qui masque le vrai problème. Il est supprimé.
Étape 2 — S'assurer que le head est swappé par View Transitions
Astro expose des événements de lifecycle pour les View Transitions. L'événement astro:after-swap se déclenche après que le nouveau contenu a été injecté dans le DOM. C'est le moment de vérifier — et si nécessaire forcer — la mise à jour du head.
Le correctif dans le layout :
---
// src/layouts/BaseLayout.astro — version corrigée
import { ViewTransitions } from 'astro:transitions';
interface Props {
title: string;
description: string;
canonical: string;
}
const { title, description, canonical } = Astro.props;
---
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<ViewTransitions />
</head>
<body>
<slot />
<script>
document.addEventListener('astro:after-swap', () => {
// Astro swap le head par défaut, mais certains éléments
// peuvent être ignorés si un script tiers les a modifiés.
// Ce listener force la synchronisation du title avec le DOM swappé.
const newTitle = document.querySelector('title');
if (newTitle) {
document.title = newTitle.textContent || '';
}
});
</script>
</body>
</html>
Étape 3 — Utiliser transition:persist avec précaution
L'équipe avait ajouté transition:persist sur le header et le footer pour éviter de les recharger. Ce directive indique à Astro de conserver l'élément entre les navigations. Problème : si un composant dans le header lit des meta du <head> au mount et les cache, il affiche des données obsolètes.
La règle : ne jamais persister un composant qui dépend du <head> de la page courante.
Étape 4 — Upgrade vers Astro 4.9+
À partir d'Astro 4.9, le mécanisme de head swap a été renforcé. Le framework fait un diff plus agressif entre le <head> de la page courante et celui de la page cible, et met à jour chaque élément individuellement. La documentation Astro sur les View Transitions détaille ce comportement.
La mise à jour :
# Upgrade Astro
npm install astro@latest
# Vérifier la version
npx astro --version
# astro v4.12.2
# Rebuild et test
npm run build
npm run preview
Étape 5 — Ajouter un test automatisé
L'équipe ajoute un test Playwright qui simule une navigation SPA et vérifie le <head> :
// tests/view-transitions-head.spec.ts
import { test, expect } from '@playwright/test';
test('View Transitions met à jour le head sur navigation interne', async ({ page }) => {
// Arriver sur la homepage
await page.goto('https://lemag-outdoor.fr/');
const homeTitle = await page.title();
expect(homeTitle).toContain('Accueil');
// Naviguer vers un article via clic (déclenche View Transitions)
await page.click('a[href="/articles/sentiers-chartreuse"]');
await page.waitForURL('**/articles/sentiers-chartreuse');
// Vérifier que le title a changé
const articleTitle = await page.title();
expect(articleTitle).not.toContain('Accueil');
expect(articleTitle).toContain('Chartreuse');
// Vérifier la meta description
const description = await page.getAttribute('meta[name="description"]', 'content');
expect(description).not.toContain('magazine outdoor');
// Vérifier le canonical
const canonical = await page.getAttribute('link[rel="canonical"]', 'href');
expect(canonical).toBe('https://lemag-outdoor.fr/articles/sentiers-chartreuse');
});
Invalidation du cache et redéploiement
Le fix est déployé un mercredi à 11h. Le cache Vercel est purgé manuellement via le dashboard. Les pages les plus touchées sont soumises à la réindexation via l'outil d'inspection d'URL de Search Console — par lots de 50 par jour, en commençant par les pages avec le plus de trafic historique.
Temps de récupération
- J+3 : Google recrawle 60% des pages soumises. Les titres dans les SERPs commencent à revenir à la normale.
- J+7 : 90% des pages affichent le bon titre dans Search Console.
- J+14 : Les clics remontent à 85% du niveau pré-incident.
- J+21 : Retour au niveau de trafic normal. Certaines pages longue traîne mettent 4 semaines supplémentaires à retrouver leur position exacte.
Ce schéma de récupération est cohérent avec d'autres incidents de meta corrompues. Le même type de timeline a été observé lors d'un problème similaire sur les content collections Astro où des titres frontmatter ne passaient plus après une refactorisation.
Ce qu'on en retient
Le mode SPA des frameworks n'est pas une fonctionnalité SEO-neutre. Chaque mécanisme qui intercepte la navigation native du navigateur crée un risque de divergence entre le HTML servi et le DOM visible. Les View Transitions d'Astro, comme les transitions équivalentes dans Next.js, Nuxt ou SvelteKit, ajoutent une couche d'abstraction sur la navigation — et cette couche doit être testée spécifiquement pour le <head>.
Les tests manuels ne suffisent pas. Un développeur qui recharge la page verra toujours le bon titre. Il faut tester la navigation client-side, pas le rendu initial. Un monitoring continu type Seogard détecte cette divergence SSR/DOM en quelques minutes, parce qu'il compare systématiquement le HTML brut et le DOM rendu après hydratation — exactement le cas de figure que les tests manuels ratent.
Trois règles à graver : tester le <head> après navigation SPA, pas seulement après page load. Ne jamais persister un composant qui lit les meta de la page. Et traiter le canonical comme la donnée la plus critique du <head> — un canonical incorrect fait plus de dégâts qu'un <title> mal formaté.