Migration Gatsby → Astro : RSS feed orphelin, 6 semaines

Migration Gatsby vers Astro : un RSS feed orphelin pendant 6 semaines

Mardi 14 janvier, 22h30. L'équipe contenu d'un éditeur SaaS français (blog technique, 380 articles, 28 000 visites organiques/mois) célèbre le déploiement du nouveau blog. Gatsby 4 est mort, Astro 4.x tourne en production. Le Lighthouse affiche 98. Le design est propre. Tout le monde rentre. Personne ne pense au fichier /rss.xml. Six semaines plus tard, un développeur advocate remarque que Feedly affiche "Feed not found" sur le blog. L'investigation commence.

Mercredi 26 février, 9h12 — "Plus personne ne nous lit ?"

L'alerte ne vient pas de Search Console. Pas de Slack bot. Pas de monitoring. Elle vient d'un tweet.

"Hey @[marque], votre RSS est cassé depuis un moment, Feedly me renvoie une erreur."

Le dev advocate forward le message au lead contenu. Première réaction : "On a un RSS ?" Oui. Gatsby le générait via gatsby-plugin-feed. L'URL était /rss.xml. Chaque article publié apparaissait dans le feed, et une poignée de newsletters tierces, trois agrégateurs sectoriels et environ 1 200 abonnés Feedly tiraient ce flux automatiquement.

Le lead contenu ouvre https://blog.exemple.com/rss.xml dans un navigateur. Réponse : 404.

Il essaie /feed.xml. Réponse : un XML valide. Astro sert le feed depuis @astrojs/rss, et le fichier de sortie par défaut est feed.xml, pas rss.xml.

Premier réflexe : vérifier depuis combien de temps le problème existe. Le déploiement Astro date du 14 janvier. On est le 26 février. 43 jours.

Le lead contenu tire les chiffres. Dans GA4, le segment "referral" filtré sur les domaines connus d'agrégateurs (feedly.com, inoreader.com, newsblur.com, theoldreader.com) montre un effondrement net :

  • Décembre (dernier mois complet sous Gatsby) : 3 840 sessions referral via agrégateurs RSS.
  • Janvier (bascule le 14) : 2 100 sessions. La moitié du mois fonctionnait encore.
  • Février (au 26) : 310 sessions.

L'inbound RSS représentait 14 % du trafic referral total du blog. Ce canal est tombé à quasi-zéro sans que personne ne le remarque. Aucune alerte. Aucun dashboard ne suivait ce flux. L'équipe SEO surveillait les positions organiques et les Core Web Vitals. Le RSS n'existait dans aucun runbook de migration.

Le CTO demande un état des lieux complet. Combien de sources externes pointent encore vers /rss.xml ? Un crawl rapide via Screaming Frog sur le backlink profile (export Ahrefs) révèle 17 liens entrants pointant explicitement vers /rss.xml. Trois newsletters sectorielles. Deux portails de curation. Le reste : des pages "blogroll" de développeurs individuels.

Tous ces liens renvoient désormais un 404. Les agrégateurs ont progressivement abandonné le polling. Feedly, après quelques tentatives en erreur, marque le feed comme inactif. Les 1 200 abonnés ne reçoivent plus rien. Ils ne le savent pas. Ils n'ont reçu aucune notification. Le feed a simplement cessé de se mettre à jour dans leur interface.

Personne n'a crié. C'est le problème des régressions RSS : les utilisateurs ne reviennent pas se plaindre. Ils oublient.

Le bug : deux URLs, zéro redirect, un fichier fantôme

Pour comprendre comment c'est arrivé, il faut remonter à la configuration des deux stacks.

Côté Gatsby : le feed historique

Dans gatsby-config.js, le plugin RSS était configuré ainsi :

// gatsby-config.js (Gatsby 4)
module.exports = {
  plugins: [
    {
      resolve: `gatsby-plugin-feed`,
      options: {
        query: `
          {
            site {
              siteMetadata {
                title
                description
                siteUrl
              }
            }
          }
        `,
        feeds: [
          {
            serialize: ({ query: { site, allMarkdownRemark } }) => {
              return allMarkdownRemark.nodes.map(node => ({
                title: node.frontmatter.title,
                description: node.excerpt,
                date: node.frontmatter.date,
                url: site.siteMetadata.siteUrl + node.fields.slug,
                guid: site.siteMetadata.siteUrl + node.fields.slug,
              }))
            },
            query: `
              {
                allMarkdownRemark(sort: {frontmatter: {date: DESC}}) {
                  nodes {
                    excerpt
                    fields { slug }
                    frontmatter { title date }
                  }
                }
              }
            `,
            output: "/rss.xml",
            title: "Blog Exemple — RSS Feed",
          },
        ],
      },
    },
  ],
}

Le point clé : output: "/rss.xml". C'est cette URL que les agrégateurs connaissent. C'est cette URL qui est linkée dans les <link rel="alternate"> du <head> HTML. C'est cette URL que 1 200 personnes ont collée dans Feedly.

Côté Astro : le nouveau feed

L'équipe installe @astrojs/rss et crée le endpoint selon la documentation officielle :

// src/pages/feed.xml.ts (Astro 4.x)
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';

export async function GET(context: APIContext) {
  const posts = await getCollection('blog');
  return rss({
    title: 'Blog Exemple',
    description: 'Articles techniques sur le développement web',
    site: context.site!,
    items: posts.map((post) => ({
      title: post.data.title,
      pubDate: post.data.date,
      description: post.data.description,
      link: `/blog/${post.slug}/`,
    })),
    customData: `<language>fr-fr</language>`,
  });
}

Le fichier s'appelle feed.xml.ts. Astro génère donc la route /feed.xml. Pas /rss.xml.

La documentation d'Astro utilise feed.xml dans ses exemples. Le développeur qui a configuré le RSS a suivi la doc. Il n'a pas pensé à vérifier l'URL de l'ancien feed. L'ancien feed n'était documenté nulle part dans le runbook de migration.

Ce que voit le navigateur vs ce que voient les agrégateurs

Un humain qui visite le blog ne voit rien de cassé. Le <head> du nouveau site contient bien un lien vers le feed — mais vers le nouveau :

<!-- <head> du site Astro en production -->
<link
  rel="alternate"
  type="application/rss+xml"
  title="Blog Exemple"
  href="https://blog.exemple.com/feed.xml"
/>

Aucun problème pour un nouveau visiteur qui découvre le blog et cherche le RSS. Mais les 1 200 abonnés existants ne revisitent pas la page d'accueil pour trouver le nouveau lien. Leur agrégateur continue de poller /rss.xml, qui renvoie un 404.

Les newsletters tierces qui scrappent /rss.xml automatiquement se retrouvent face au même 404. Après 3 à 7 jours de 404 consécutifs (selon la politique de retry de chaque agrégateur), la plupart arrêtent le polling. Feedly affiche un badge "inactive" après environ 48h de 404. Certains outils comme Inoreader persistent un peu plus longtemps, mais finissent par décrocher aussi.

Pourquoi les tests n'ont rien détecté

Le checklist de migration contenait 34 points. Voici ce qui était vérifié :

  • Redirections 301 de toutes les anciennes URLs d'articles : ✅
  • Sitemap XML présent et soumis à Search Console : ✅
  • Canonical tags corrects : ✅
  • Meta robots : ✅
  • Core Web Vitals post-déploiement : ✅
  • RSS feed : absent de la checklist.

Le crawl Screaming Frog pré-déploiement était configuré pour scanner les URLs du sitemap + les liens internes. Le RSS feed n'apparaissait dans aucun sitemap (c'est normal, les fichiers RSS ne sont pas indexés). Il n'était linké en <a href> nulle part dans le body des pages — uniquement en <link rel="alternate"> dans le <head>.

Screaming Frog, par défaut, crawle les <link> du <head>. Mais le crawl de validation a été lancé après le déploiement Astro. Le nouveau <head> pointe vers /feed.xml, qui répond 200. Le crawl est vert. L'ancien /rss.xml n'est référencé nulle part sur le nouveau site. Il n'apparaît donc jamais dans le crawl.

Le problème fondamental : la régression se situe dans la rupture d'une URL externe qui n'existe plus dans le périmètre du nouveau site. Les outils de crawl vérifient ce qui existe. Pas ce qui a disparu.

Pour détecter cette régression, il aurait fallu un diff entre les URLs servies par l'ancien site et celles servies par le nouveau. Un crawl comparatif. Ou un monitoring des URLs critiques non-HTML (RSS, sitemap, robots.txt, fichiers JSON-LD standalone) avec alerting sur changement de status code.

L'effet cascade sur le SEO indirect

Le RSS feed ne contribue pas directement au ranking Google. Googlebot ne crawle pas les feeds RSS pour indexer les pages (il utilise le sitemap et les liens internes). Mais le RSS avait un effet indirect mesurable :

  1. Trois newsletters sectorielles tiraient leur contenu du feed. Chaque article publié générait un lien entrant depuis l'archive de la newsletter. Depuis le 14 janvier, zéro nouveau lien entrant via ce canal.
  2. Deux portails de curation technique affichaient les derniers articles du blog via le RSS. Les articles ont disparu de ces portails. Perte de visibilité indirecte.
  3. Les abonnés Feedly partageaient régulièrement les articles sur Twitter/LinkedIn. Ce relais social s'est tari.

Le trafic referral RSS (3 800 sessions/mois) n'est pas du trafic organique. Mais la perte de liens entrants générés par les newsletters a un impact organique différé. L'équipe observe, sur les 6 semaines, une baisse de 8 positions moyennes sur 12 mots-clés informationnels à forte concurrence. Corrélation, pas causalité prouvée — mais le timing coïncide.

Le fix : redirect, alias, et récupération

Étape 1 : la redirect 301 immédiate

Le fix le plus urgent : rediriger /rss.xml vers /feed.xml. Sur Astro déployé via Vercel (ce qui est le cas ici), la configuration se fait dans vercel.json :

{
  "redirects": [
    {
      "source": "/rss.xml",
      "destination": "/feed.xml",
      "permanent": true
    }
  ]
}

Pour un déploiement sur Netlify, l'équivalent irait dans netlify.toml :

[[redirects]]
  from = "/rss.xml"
  to = "/feed.xml"
  status = 301

Déploiement en 4 minutes. Vérification immédiate :

curl -sI https://blog.exemple.com/rss.xml

HTTP/2 301
location: https://blog.exemple.com/feed.xml

La redirect est en place. Mais est-ce suffisant ?

Étape 2 : garder /rss.xml comme URL canonique du feed

L'équipe discute. Deux options :

Option A : garder la redirect 301 de /rss.xml vers /feed.xml, et laisser /feed.xml comme URL principale.

Option B : renommer le fichier Astro pour que le feed soit directement servi depuis /rss.xml, sans redirect.

L'option B est plus propre. Moins de hops, pas de risque qu'un agrégateur refuse de suivre la 301. Le renommage est trivial :

// src/pages/rss.xml.ts (renommé depuis feed.xml.ts)
import rss from '@astrojs/rss';
import { getCollection } from 'astro:content';
import type { APIContext } from 'astro';

export async function GET(context: APIContext) {
  const posts = await getCollection('blog');
  return rss({
    title: 'Blog Exemple',
    description: 'Articles techniques sur le développement web',
    site: context.site!,
    items: posts.map((post) => ({
      title: post.data.title,
      pubDate: post.data.date,
      description: post.data.description,
      link: `/blog/${post.slug}/`,
    })),
    customData: `<language>fr-fr</language>`,
  });
}

Le <head> est mis à jour en conséquence :

<link
  rel="alternate"
  type="application/rss+xml"
  title="Blog Exemple"
  href="https://blog.exemple.com/rss.xml"
/>

Et une redirect 301 est ajoutée dans l'autre sens, de /feed.xml vers /rss.xml, pour les rares personnes qui auraient bookmarké la nouvelle URL pendant les 6 semaines intermédiaires.

Étape 3 : réactiver les agrégateurs

La redirect seule ne suffit pas. Feedly a marqué le feed comme inactif. Il faut forcer la réactivation.

L'équipe utilise l'outil de validation Feedly (https://feedly.com/i/subscription/feed/https://blog.exemple.com/rss.xml) pour soumettre à nouveau le feed. Feedly le re-poll dans l'heure.

Pour les autres agrégateurs, pas d'interface manuelle. Mais le simple fait que /rss.xml retourne un 200 avec un XML valide suffit. La plupart des agrégateurs retentent les feeds en erreur toutes les 24 à 72 heures avant de les désactiver définitivement. Après 43 jours de 404, certains ont probablement purgé le feed de leur base. Ces abonnés sont perdus.

L'équipe envoie un email à la liste de contacts des trois newsletters tierces pour les prévenir que le feed est rétabli. Deux répondent dans la journée et réactivent le scraping. La troisième ne répond jamais.

Étape 4 : vérification et monitoring

Validation du feed XML avec xmllint :

curl -s https://blog.exemple.com/rss.xml | xmllint --noout -
# Pas d'erreur = XML bien formé

Validation du contenu : les 20 derniers articles sont bien présents, les <link> pointent vers les bonnes URLs, les dates <pubDate> sont au format RFC 822.

Temps de récupération

  • J+1 : Feedly affiche à nouveau les articles. Les abonnés qui n'avaient pas désactivé le feed voient les 20 derniers articles apparaître d'un coup.
  • J+3 : le trafic referral via agrégateurs remonte à environ 40 % du niveau pré-migration.
  • J+14 : 75 % du niveau pré-migration. Les 25 % manquants correspondent probablement aux abonnés qui ont purgé le feed inactif de leur agrégateur et ne le réajouteront jamais.
  • J+30 : stabilisation à environ 80 % du trafic referral RSS d'avant migration. Le reste est une perte sèche.

Sur les 1 200 abonnés Feedly d'origine, environ 950 ont retrouvé le feed. 250 sont partis. Pour un blog technique, c'est 250 lecteurs engagés — le type de lecteur qui partage, qui commente, qui linke. La perte n'est pas anodine.

Le checklist de migration mis à jour

L'équipe ajoute trois lignes au runbook :

  1. Avant migration : lister toutes les URLs non-HTML servies par l'ancien site (RSS, Atom, JSON feed, sitemap, robots.txt, fichiers statiques critiques). Les inclure dans le crawl comparatif.
  2. Au déploiement : vérifier que chaque URL non-HTML de l'ancien site retourne soit un 200 soit une 301 vers l'équivalent du nouveau site.
  3. Post-déploiement : monitorer les status codes des URLs non-HTML pendant 30 jours minimum.

Ce type de régression sur les fichiers RSS touche aussi les migrations similaires. L'équipe qui migre de Nuxt 2 vers Nuxt 3 ou de Next.js Pages Router vers App Router fait face au même angle mort : les fichiers auxiliaires que personne ne checke parce qu'ils ne sont pas dans le DOM visible. Le pattern se répète aussi avec les content collections Astro qui cassent le frontmatter — un feed RSS qui tire ses données de collections mal mappées génère un XML vide ou des titres manquants, tout aussi silencieusement.

Et quand l'environnement de staging n'est pas stress-testé correctement, ce genre de divergence passe systématiquement sous le radar.

Ce qu'on en retient

Le RSS est un canal invisible. Pas de dashboard natif dans GA4. Pas de section dédiée dans Search Console. Pas d'alerte Lighthouse. Un feed qui tombe en 404 ne déclenche aucune notification, chez personne. Les agrégateurs abandonnent silencieusement. Les abonnés ne se plaignent pas — ils oublient.

Lors d'une migration de framework, chaque URL servie par l'ancien système doit être inventoriée et redirigée. Pas seulement les pages. Pas seulement le sitemap. Les feeds. Les fichiers JSON. Les endpoints API consommés par des tiers.

Un monitoring continu type Seogard, qui surveille les status codes de toutes les URLs critiques — y compris les non-HTML — aurait détecté le 404 sur /rss.xml dans les heures suivant le déploiement. Pas six semaines plus tard, via un tweet.

La prochaine migration, le RSS sera sur la checklist. Mais combien d'autres fichiers fantômes attendent dans l'ombre ?

Articles connexes

Migration25 mai 2026

React 18 Suspense SSR : next/head cassé par le streaming

Migration React 17→18 : le streaming SSR réordonne les chunks et supprime les meta tags. Récit d'incident, diagnostic complet et patch Next.js.

Migration25 mai 2026

Astro v6 : Content Collections cassent les title en silence

Après upgrade Astro v5→v6, 312 articles perdent leur balise title. Récit du bug, diagnostic frontmatter, fix et récupération SEO en 19 jours.

Migration25 mai 2026

Angular 17 SSR : hydration mismatch invisible, −34 % trafic

Migration Angular 17 vers SSR : provideServerRendering mal configuré cause un hydration mismatch invisible. Récit, diagnostic Lighthouse, fix précis.