Astro sitemap pointe vers build.local : 4 000 URLs perdues

Sitemap Astro pointant vers build.local : 4 000 URLs envoyées dans le vide

Mercredi 11h. Le lead SEO d'une marketplace de mobilier design ouvre Search Console pour vérifier la couverture post-déploiement. Le rapport Sitemaps affiche un statut vert — "Réussite". Mais la colonne "URL découvertes" indique 0 pages indexées sur les 4 127 soumises. Il clique sur le sitemap. Chaque <loc> commence par https://build.local/. Le domaine de production n'apparaît nulle part. Depuis 11 jours, Google ingère un plan de site qui pointe vers un domaine qui n'existe pas.

Lundi 9h12 — Le silence avant l'alerte

Le déploiement incriminé remonte au vendredi d'avant. L'équipe front a migré le site de Astro 4.x vers Astro 5, en profitant pour restructurer la configuration de build. Le pipeline CI/CD tourne sur GitHub Actions. Le site est hébergé sur Vercel. Tout passe au vert : build réussie, preview fonctionnelle, lighthouse score stable.

Personne ne regarde le sitemap.

Lundi matin, le lead SEO vérifie les positions sur un panel de 85 mots-clés prioritaires. Pas de mouvement anormal — Google n'a pas encore recrawlé massivement. Mais il note un détail : le dernier crawl du sitemap dans Search Console date de vendredi 17h23. Statut "Réussite", mais 0 URLs indexées depuis ce crawl.

Il télécharge le fichier sitemap-0.xml directement depuis https://www.meubles-design.example/sitemap-0.xml. Le fichier se charge. Il fait 312 Ko. Il l'ouvre.

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://build.local/canapes/canape-angle-velours-bleu</loc>
    <lastmod>2026-05-29T14:22:00.000Z</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.7</priority>
  </url>
  <url>
    <loc>https://build.local/tables/table-basse-chene-massif</loc>
    <lastmod>2026-05-29T14:22:00.000Z</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.7</priority>
  </url>
  <!-- 4 125 autres entrées identiques -->
</urlset>

Chaque URL pointe vers https://build.local. Pas https://www.meubles-design.example.

Le lead SEO envoie un message Slack à 9h34 : "Le sitemap est cassé. Toutes les URLs pointent vers un domaine local. Depuis vendredi."

L'équipe front vérifie d'abord le plugin @astrojs/sitemap. Il est bien installé, bien importé. Le build local génère le même fichier. Le dev principal pense d'abord à un bug du plugin — il ouvre une issue GitHub, cherche des rapports similaires. Rien de concluant.

À 10h15, le CTO demande l'impact. Le lead SEO sort les chiffres de Search Console sur les 10 derniers jours : le taux de crawl des URLs de contenu a chuté de 340 requêtes/jour à 87. Google continue de crawler le site via les liens internes, mais il ignore le sitemap — logiquement, puisque les URLs build.local ne résolvent vers aucune IP. Le trafic organique n'a pas encore décroché visiblement, mais les nouvelles pages produit publiées depuis vendredi — 23 fiches — n'ont été indexées nulle part.

À 10h42, le dev junior lâche dans le thread Slack : "J'ai trouvé. C'est le astro.config.mjs."

Le bug : site configuré par environnement, mais l'environnement n'existe pas en CI

Le problème est dans la propriété site du fichier astro.config.mjs. Cette propriété est documentée par Astro comme la source de vérité pour générer les URLs absolues — dans le sitemap, dans les flux RSS, dans les balises canonical.

Voici la configuration telle qu'elle existait avant la migration Astro 5 :

// astro.config.mjs — version AVANT migration (Astro 4.x)
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://www.meubles-design.example',
  integrations: [sitemap()],
});

Direct. Fonctionnel. Le domaine de production est en dur.

Lors de la migration vers Astro 5, l'équipe front a voulu "professionnaliser" la config. Le dev principal a introduit une gestion par variable d'environnement, pour distinguer les builds de développement, de staging et de production :

// astro.config.mjs — version APRÈS migration (Astro 5)
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

const siteUrl = process.env.SITE_URL || 'https://build.local';

export default defineConfig({
  site: siteUrl,
  integrations: [
    sitemap({
      changefreq: 'weekly',
      priority: 0.7,
      lastmod: new Date(),
    }),
  ],
  output: 'static',
  build: {
    format: 'directory',
  },
});

La logique : en local, SITE_URL n'est pas défini, donc le fallback https://build.local s'applique. En CI, la variable devrait être injectée.

Le problème : elle ne l'est pas.

Le workflow GitHub Actions ressemble à ceci :

# .github/workflows/deploy.yml
name: Deploy to Vercel
on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
        env:
          NODE_ENV: production
          STRAPI_API_URL: ${{ secrets.STRAPI_API_URL }}
          STRAPI_API_TOKEN: ${{ secrets.STRAPI_API_TOKEN }}
          # SITE_URL manquant ici
      - uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}

SITE_URL n'est jamais défini dans les secrets GitHub, ni dans les variables d'environnement du job de build. Le fallback 'https://build.local' s'applique silencieusement. Astro ne lève aucun warning. Le build réussit. Le sitemap est généré. Vercel déploie le résultat.

Ce que voit le développeur vs ce que voit Googlebot

Le développeur ouvre le site sur https://www.meubles-design.example/canapes/canape-angle-velours-bleu. La page se charge. Le HTML est correct. Les balises meta sont en place. Le canonical pointe vers la bonne URL — parce que l'équipe utilise Astro.url dans le layout, qui se base sur l'URL de la requête HTTP en prod, pas sur site.

---
// src/layouts/BaseLayout.astro
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---
<link rel="canonical" href={canonicalURL.href} />

Mais Astro.site retourne la valeur de la propriété site dans astro.config.mjs. En prod statique sur Vercel, Astro.site vaut https://build.local. Le canonical rendu est donc :

<link rel="canonical" href="https://build.local/canapes/canape-angle-velours-bleu" />

Double peine. Le sitemap ET les canonicals pointent vers build.local. Le navigateur ne montre rien d'anormal — l'utilisateur humain ne regarde pas les balises <link> dans le <head>. Mais Googlebot voit deux signaux cohérents qui disent : "L'URL officielle de cette page est sur build.local."

Pour vérifier, un curl sur le HTML brut suffit :

curl -s https://www.meubles-design.example/canapes/canape-angle-velours-bleu \
  | grep -i 'canonical\|og:url'

Résultat :

<link rel="canonical" href="https://build.local/canapes/canape-angle-velours-bleu" />
<meta property="og:url" content="https://build.local/canapes/canape-angle-velours-bleu" />

Les trois signaux — sitemap, canonical, og:url — convergent vers un domaine inexistant.

Pourquoi les tests n'ont rien détecté

L'équipe avait trois couches de tests :

  1. Tests unitaires sur les composants Astro — aucun ne teste le rendu HTML des balises <head>.
  2. Lighthouse CI en post-build — il vérifie les scores de performance, pas le contenu du sitemap ni la valeur des canonicals.
  3. Preview Vercel — l'URL de preview est https://meubles-design-git-main-team.vercel.app. Personne n'a ouvert le sitemap de la preview. Et même en l'ouvrant, les URLs build.local n'auraient pas semblé plus suspectes que les URLs de preview — aucune ne correspondait à la prod.

Le problème fondamental : aucun test automatisé ne comparait les URLs générées dans le sitemap au domaine de production attendu. C'est un test qui n'existe presque jamais dans les pipelines CI classiques.

Un crawl Screaming Frog du site de production aurait immédiatement signalé le problème : chaque canonical et og:url aurait été flaggé comme "URL ne correspondant pas au domaine crawlé". Mais le dernier crawl complet datait d'avant la migration.

L'ampleur du problème

Le site contient 4 127 pages indexables : 3 840 fiches produit, 187 pages de catégories, 54 pages de contenu éditorial, 46 pages techniques (CGV, FAQ, guides). Le sitemap sitemap-0.xml les liste toutes avec le domaine build.local.

Pendant 11 jours :

  • Google a crawlé le sitemap 3 fois (visible dans le rapport Sitemaps de Search Console).
  • À chaque crawl, il a tenté de résoudre build.local. Échec DNS. Les 4 127 URLs ont été ignorées.
  • Le crawl organique via les liens internes continuait, mais Google trouvait des canonicals pointant vers build.local, ce qui créait une confusion de signal.
  • Les 23 nouvelles fiches produit ajoutées pendant cette période n'avaient aucun lien interne ancien — elles dépendaient du sitemap pour la découverte. Elles n'ont pas été indexées.

Ce type de régression silencieuse rappelle les incidents de canonicals pointant vers le staging, à la différence que le domaine ici n'a jamais existé du tout.

Le fix : variable d'environnement, validation au build, et tests de sitemap

Étape 1 — Le patch immédiat

Le correctif minimal : ajouter SITE_URL dans les secrets GitHub Actions et durcir le fallback pour qu'il échoue bruyamment plutôt que silencieusement.

// astro.config.mjs — version corrigée
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

const siteUrl = process.env.SITE_URL;

if (!siteUrl) {
  throw new Error(
    '[astro.config] SITE_URL is not defined. ' +
    'Set it in your environment variables. ' +
    'Build aborted to prevent sitemap/canonical corruption.'
  );
}

try {
  new URL(siteUrl);
} catch {
  throw new Error(
    `[astro.config] SITE_URL "${siteUrl}" is not a valid URL. Build aborted.`
  );
}

export default defineConfig({
  site: siteUrl,
  integrations: [
    sitemap({
      changefreq: 'weekly',
      priority: 0.7,
      lastmod: new Date(),
    }),
  ],
  output: 'static',
  build: {
    format: 'directory',
  },
});

Plus de fallback silencieux. Si SITE_URL est absent, le build plante immédiatement avec un message explicite. Le new URL() valide en plus que la valeur est une URL syntaxiquement correcte.

Le workflow CI est mis à jour :

# .github/workflows/deploy.yml — section corrigée
      - run: npm run build
        env:
          NODE_ENV: production
          SITE_URL: ${{ secrets.SITE_URL }}
          STRAPI_API_URL: ${{ secrets.STRAPI_API_URL }}
          STRAPI_API_TOKEN: ${{ secrets.STRAPI_API_TOKEN }}

Le secret SITE_URL est ajouté dans les settings du repository GitHub avec la valeur https://www.meubles-design.example.

Étape 2 — Rebuild et redéploiement

Le rebuild est lancé à 11h30. Le sitemap généré est vérifié avant que le déploiement ne soit promu en production :

# Vérification post-build, avant deploy
grep -c 'build.local' dist/sitemap-0.xml
# Attendu : 0

grep -c 'www.meubles-design.example' dist/sitemap-0.xml
# Attendu : 4127

head -20 dist/sitemap-0.xml

Le sitemap est propre. Le déploiement passe en production à 11h47.

Étape 3 — Forcer le recrawl

Le lead SEO soumet à nouveau le sitemap dans Search Console. Il utilise aussi l'outil d'inspection d'URL sur les 23 fiches produit non indexées pour demander une indexation individuelle.

Les canonicals dans le HTML sont également corrigés par le rebuild — puisque Astro.site retourne désormais la bonne valeur.

Vérification post-déploiement :

curl -s https://www.meubles-design.example/canapes/canape-angle-velours-bleu \
  | grep -i 'canonical\|og:url'
<link rel="canonical" href="https://www.meubles-design.example/canapes/canape-angle-velours-bleu" />
<meta property="og:url" content="https://www.meubles-design.example/canapes/canape-angle-velours-bleu" />

Correct.

Étape 4 — Test automatisé dans le pipeline

L'équipe ajoute un script de validation post-build dans le CI :

#!/bin/bash
# scripts/validate-sitemap.sh

EXPECTED_DOMAIN="www.meubles-design.example"
SITEMAP="dist/sitemap-0.xml"

if [ ! -f "$SITEMAP" ]; then
  echo "❌ Sitemap not found at $SITEMAP"
  exit 1
fi

BAD_URLS=$(grep -c '<loc>' "$SITEMAP")
GOOD_URLS=$(grep "<loc>" "$SITEMAP" | grep -c "$EXPECTED_DOMAIN")

if [ "$BAD_URLS" -ne "$GOOD_URLS" ]; then
  echo "❌ Sitemap contains URLs not matching $EXPECTED_DOMAIN"
  echo "Total <loc> entries: $BAD_URLS"
  echo "Matching $EXPECTED_DOMAIN: $GOOD_URLS"
  grep "<loc>" "$SITEMAP" | grep -v "$EXPECTED_DOMAIN" | head -5
  exit 1
fi

echo "✅ Sitemap OK: $GOOD_URLS URLs, all on $EXPECTED_DOMAIN"

Ce script est appelé dans le workflow CI entre le build et le déploiement. Si un seul <loc> ne contient pas le bon domaine, le pipeline échoue.

Temps de récupération

  • J+2 : Google a recrawlé le sitemap. Les URLs sont de nouveau reconnues.
  • J+5 : les 23 fiches produit manquantes commencent à apparaître dans l'index.
  • J+9 : le taux de crawl remonte à 310 requêtes/jour (niveau pré-incident).
  • J+14 : la couverture Search Console revient à 4 100+ pages indexées. Pas de perte de position mesurable sur les mots-clés historiques — le crawl via liens internes avait maintenu l'essentiel.

Le vrai coût : 11 jours d'indexation perdue pour les nouvelles pages, et un risque réputationnel si Google avait commencé à désindexer les pages existantes faute de canonical cohérent. Sur un site à 18 000 sessions organiques/mois, le lead SEO estime le manque à gagner à environ 850 sessions sur la période — essentiellement du trafic longue traîne sur les nouvelles fiches.

Ce schéma — un domaine de staging ou de build qui fuit en production — est un classique des migrations de framework. L'incident est structurellement identique aux redirections oubliées lors de migrations CMS ou aux problèmes de configuration RSS orpheline lors de passages à Astro : un artefact de build qui n'est jamais testé parce qu'il est invisible dans le navigateur.

Ce qu'on en retient

Trois règles émergent de cet incident.

Un : ne jamais utiliser de fallback silencieux pour la propriété site d'Astro. Si la variable d'environnement manque, le build doit planter. Un build cassé vaut infiniment mieux qu'un sitemap corrompu déployé en production.

Deux : tester le sitemap dans le CI. Pas visuellement. Pas manuellement. Un grep de 10 lignes suffit à vérifier que chaque <loc> contient le bon domaine.

Trois : la divergence entre "ce que voit le navigateur" et "ce que voit le crawler" est le terrain de jeu favori des régressions SEO. Un outil de monitoring continu comme Seogard détecte ce type de divergence — canonicals incohérents, sitemap pointant vers un mauvais domaine — en quelques minutes après le déploiement, pas onze jours plus tard dans un rapport Search Console.

Le sitemap est un fichier statique. Il est généré une fois, déployé une fois, et personne ne le regarde. C'est exactement pour ça qu'il casse en silence.

Articles connexes

Performance7 juin 2026

Variable font lazy-load : LCP dégradé de 1.2s, ranking en chute

Une refonte typo charge la police en lazy. Le LCP passe de 1.8s à 3.0s. Aucune meta ne bouge. Le trafic chute de 18%. Récit, diagnostic, fix.

Rendering6 juin 2026

noscript cloaking : splash screen SPA piège Google

Un e-commerce SPA cache son contenu dans une balise noscript pour les bots. Google détecte du cloaking. Récit, diagnostic et fix complet.

Actualités SEO6 juin 2026

57% de bots : impact SEO et stratégies de défense technique

Cloudflare révèle que 57% des requêtes web sont des bots. Analyse technique des impacts SEO et stratégies concrètes pour protéger votre crawl budget.

Actualités SEO5 juin 2026

May 2025 Core Update : intent matching et signaux techniques

Analyse technique du May 2025 Core Update de Google : comment l'alignement intent/contenu et les signaux techniques déterminent les gagnants et perdants.

Refonte5 juin 2026

Dark mode CSS-in-JS injecte un noindex : récit et fix

Un CSS-in-JS mal configuré injecte un meta noindex via prefers-color-scheme. Récit de l'incident, diagnostic technique, et correctif complet.

Refonte4 juin 2026

Design system React : un Heading en div détruit la sémantique de 1 200 pages

Un composant Heading React mal configuré rend des div au lieu de h1-h6. Récit de l'incident, diagnostic du diff, fix et récupération SEO.