Un développeur pousse un refactor de composant layout un jeudi à 16h. La pull request passe les tests unitaires, les tests e2e, le linting. Le déploiement se fait en 4 minutes. Le lendemain matin, 3 200 pages produit d'un e-commerce à 18 000 URLs renvoient une balise <title> identique — le nom du site, sans le titre produit. Googlebot crawle le site dans la nuit. En 72 heures, le trafic organique sur les fiches produit chute de 34%.
Ce scénario n'est pas théorique. C'est le type exact de régression SEO que les pipelines CI/CD classiques laissent passer, parce qu'aucune étape ne vérifie les fondamentaux SEO. Vos tests valident que l'application fonctionne. Ils ne valident pas que l'application est indexable.
Pourquoi les tests classiques ne couvrent pas le SEO
Les suites de tests front-end — Jest, Vitest, Cypress, Playwright — sont conçues pour valider le comportement fonctionnel. Un composant rend le bon texte. Un formulaire soumet les bonnes données. Une navigation amène à la bonne route. Aucun de ces tests ne vérifie par défaut qu'une page possède une balise canonical correcte, un <h1> unique, ou un status code 200 sur l'URL canonique.
Le gap entre "ça marche" et "c'est indexable"
Le rendu côté serveur (SSR) est un bon exemple de ce gap. Un composant React peut parfaitement fonctionner côté client tout en rendant un <head> vide côté serveur. Les tests Cypress passent — ils tournent dans un navigateur qui exécute le JavaScript. Mais Googlebot, dans certains cas, va voir la version SSR initiale. Si les meta y sont absentes, vous avez un problème invisible à votre pipeline.
Ce type de divergence entre SSR et CSR est l'un des cas les plus fréquents de régression SEO silencieuse. Les tests e2e ne le détectent pas parce qu'ils ne testent pas ce que le crawler voit.
Ce qu'un check SEO doit valider
Un check SEO dans un pipeline CI/CD ne remplace pas un audit complet. Il sert de gate — un ensemble de conditions minimales qui, si elles ne sont pas remplies, bloquent le déploiement. Les vérifications critiques sont :
- Présence et unicité de
<title>et<meta name="description">sur un échantillon de pages - Présence d'un
<h1>unique par page - Cohérence des balises canonical (pas d'auto-canonical vers une 404, pas de canonical manquant)
- Status codes HTTP corrects (pas de soft 404 sur des pages qui devraient être en 200)
- Contenu SSR identique au CSR pour les éléments SEO-critiques
- Absence de
noindexaccidentel sur les pages stratégiques - Validité du sitemap.xml (parsable, URLs en 200)
Architecture d'un pipeline avec checks SEO
L'intégration se fait à deux niveaux : les checks statiques (sans serveur) qui analysent le HTML généré au build, et les checks dynamiques (avec serveur) qui font des requêtes HTTP sur un environnement de preview.
Checks statiques au build
Si votre site est généré statiquement (Next.js avec output: 'export', Astro, Hugo, Gatsby), vous pouvez analyser les fichiers HTML directement dans le filesystem après le build. C'est rapide, déterministe, et ne nécessite aucun serveur.
Voici un script Node.js qui parcourt un répertoire de build et valide les fondamentaux SEO :
// scripts/seo-check-static.ts
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { parse } from 'node-html-parser';
interface SeoViolation {
file: string;
rule: string;
detail: string;
}
async function getHtmlFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true, recursive: true });
return entries
.filter(e => e.isFile() && e.name.endsWith('.html'))
.map(e => join(e.parentPath || e.path, e.name));
}
async function checkFile(filePath: string): Promise<SeoViolation[]> {
const violations: SeoViolation[] = [];
const html = await readFile(filePath, 'utf-8');
const root = parse(html);
// Title
const titles = root.querySelectorAll('title');
if (titles.length === 0) {
violations.push({ file: filePath, rule: 'missing-title', detail: 'No <title> tag found' });
} else if (titles.length > 1) {
violations.push({ file: filePath, rule: 'duplicate-title', detail: `${titles.length} <title> tags found` });
} else if (titles[0].textContent.trim().length < 10) {
violations.push({ file: filePath, rule: 'short-title', detail: `Title is ${titles[0].textContent.trim().length} chars` });
}
// Meta description
const metaDesc = root.querySelector('meta[name="description"]');
if (!metaDesc) {
violations.push({ file: filePath, rule: 'missing-meta-description', detail: 'No meta description found' });
} else if ((metaDesc.getAttribute('content') || '').length < 50) {
violations.push({ file: filePath, rule: 'short-meta-description', detail: 'Meta description under 50 chars' });
}
// H1
const h1s = root.querySelectorAll('h1');
if (h1s.length === 0) {
violations.push({ file: filePath, rule: 'missing-h1', detail: 'No <h1> tag found' });
} else if (h1s.length > 1) {
violations.push({ file: filePath, rule: 'multiple-h1', detail: `${h1s.length} <h1> tags found` });
}
// Canonical
const canonical = root.querySelector('link[rel="canonical"]');
if (!canonical) {
violations.push({ file: filePath, rule: 'missing-canonical', detail: 'No canonical link found' });
}
// Noindex accidentel
const robotsMeta = root.querySelector('meta[name="robots"]');
if (robotsMeta && robotsMeta.getAttribute('content')?.includes('noindex')) {
violations.push({ file: filePath, rule: 'noindex-detected', detail: 'Page has noindex directive' });
}
return violations;
}
async function main() {
const buildDir = process.argv[2] || './out';
const files = await getHtmlFiles(buildDir);
console.log(`Checking ${files.length} HTML files in ${buildDir}...`);
const allViolations: SeoViolation[] = [];
for (const file of files) {
const v = await checkFile(file);
allViolations.push(...v);
}
if (allViolations.length > 0) {
console.error(`\n❌ ${allViolations.length} SEO violations found:\n`);
for (const v of allViolations) {
console.error(` [${v.rule}] ${v.file}: ${v.detail}`);
}
process.exit(1);
}
console.log(`\n✅ All ${files.length} files passed SEO checks.`);
}
main();
Ce script s'exécute en quelques secondes sur 18 000 fichiers HTML. Il n'ajoute quasiment rien au temps de build. L'appel dans un workflow GitHub Actions :
# .github/workflows/deploy.yml
name: Build & Deploy
on:
push:
branches: [main]
jobs:
build:
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
# SEO checks statiques — bloquants
- name: SEO Static Checks
run: npx tsx scripts/seo-check-static.ts ./out
# Le déploiement ne se lance que si les checks passent
- name: Deploy to production
if: success()
run: npm run deploy
Si une seule violation est détectée, le pipeline s'arrête. Le déploiement ne part pas. Le développeur voit exactement quel fichier pose problème et quelle règle est violée.
Checks dynamiques sur un environnement de preview
Pour les sites avec SSR (Next.js en mode server, Nuxt, SvelteKit), les checks statiques ne suffisent pas. Vous devez lancer un serveur, faire des requêtes HTTP, et valider les réponses. C'est là que les choses se compliquent — mais c'est aussi là que vous attrapez les régressions les plus dangereuses.
Le principe : déployer sur un environnement de staging/preview, puis exécuter une batterie de requêtes HTTP qui valident le HTML renvoyé par le serveur.
// scripts/seo-check-dynamic.ts
import { parse } from 'node-html-parser';
interface PageCheck {
url: string;
expectedTitle?: RegExp;
expectedCanonical?: string;
shouldBeIndexable: boolean;
}
// Échantillon stratégique : homepage, catégories clés, fiches produit, pages de contenu
const PAGES_TO_CHECK: PageCheck[] = [
{
url: '/',
expectedTitle: /Acme Store/,
expectedCanonical: 'https://acme-store.com/',
shouldBeIndexable: true,
},
{
url: '/categories/chaussures-running',
expectedTitle: /chaussures.+running/i,
expectedCanonical: 'https://acme-store.com/categories/chaussures-running',
shouldBeIndexable: true,
},
{
url: '/produit/nike-pegasus-41',
expectedTitle: /nike pegasus 41/i,
shouldBeIndexable: true,
},
{
url: '/compte/commandes',
shouldBeIndexable: false, // Cette page DOIT être en noindex
},
// Vérifier qu'une page filtrée n'est pas indexable
{
url: '/categories/chaussures-running?color=red&size=42',
shouldBeIndexable: false,
},
];
async function checkPage(baseUrl: string, page: PageCheck): Promise<string[]> {
const errors: string[] = [];
const fullUrl = `${baseUrl}${page.url}`;
const response = await fetch(fullUrl, {
headers: { 'User-Agent': 'SEO-CI-Check/1.0' },
redirect: 'manual', // Ne pas suivre les redirects automatiquement
});
// Vérifier le status code
if (page.shouldBeIndexable && response.status !== 200) {
errors.push(`${page.url}: Expected 200, got ${response.status}`);
return errors; // Pas la peine d'aller plus loin
}
const html = await response.text();
const root = parse(html);
// Vérifier le title
const title = root.querySelector('title')?.textContent || '';
if (page.expectedTitle && !page.expectedTitle.test(title)) {
errors.push(`${page.url}: Title "${title}" does not match ${page.expectedTitle}`);
}
// Vérifier le canonical
const canonical = root.querySelector('link[rel="canonical"]')?.getAttribute('href');
if (page.expectedCanonical && canonical !== page.expectedCanonical) {
errors.push(`${page.url}: Canonical "${canonical}" !== "${page.expectedCanonical}"`);
}
// Vérifier indexabilité
const robotsMeta = root.querySelector('meta[name="robots"]')?.getAttribute('content') || '';
const isNoindex = robotsMeta.includes('noindex');
if (page.shouldBeIndexable && isNoindex) {
errors.push(`${page.url}: Page should be indexable but has noindex`);
}
if (!page.shouldBeIndexable && !isNoindex) {
errors.push(`${page.url}: Page should NOT be indexable but lacks noindex`);
}
// Vérifier la présence de contenu SSR (pas un shell vide)
const bodyText = root.querySelector('body')?.textContent?.trim() || '';
if (page.shouldBeIndexable && bodyText.length < 200) {
errors.push(`${page.url}: Body content suspiciously short (${bodyText.length} chars) — possible SSR failure`);
}
return errors;
}
async function main() {
const baseUrl = process.env.PREVIEW_URL || 'http://localhost:3000';
console.log(`Running dynamic SEO checks against ${baseUrl}...`);
const allErrors: string[] = [];
for (const page of PAGES_TO_CHECK) {
const errors = await checkPage(baseUrl, page);
allErrors.push(...errors);
}
if (allErrors.length > 0) {
console.error(`\n❌ ${allErrors.length} SEO issues found:\n`);
allErrors.forEach(e => console.error(` ${e}`));
process.exit(1);
}
console.log(`\n✅ All ${PAGES_TO_CHECK.length} pages passed dynamic SEO checks.`);
}
main();
Deux points importants dans ce script. D'abord, l'option redirect: 'manual' : vous voulez savoir si une page redirige de manière inattendue, pas suivre silencieusement la redirection et valider la page d'arrivée. Ensuite, le check sur la longueur du body — c'est un heuristique grossier mais efficace pour détecter un SSR cassé qui renvoie un shell HTML vide avec juste un <div id="root"></div>.
La liste PAGES_TO_CHECK est l'élément le plus critique. Vous ne pouvez pas tester 18 000 pages dans un pipeline CI — c'est trop lent et trop fragile. Vous testez un échantillon stratégique : les templates principaux (homepage, catégorie, fiche produit, page de contenu, page utilitaire), plus les cas limites connus (URLs à facettes qui doivent être en noindex, pages protégées). Si le template fiche produit est cassé, une seule URL suffit à le détecter.
Scénario concret : migration SSR d'un e-commerce
Prenons un cas réel. Un e-commerce mode avec 15 200 pages (420 catégories, 12 800 fiches produit, 1 980 pages de contenu/lookbooks) migre de Create React App (full CSR) vers Next.js 14 avec App Router et SSR. Le trafic organique représente 62% des visites — environ 185 000 sessions/mois.
Avant l'intégration CI/CD
L'équipe fait la migration en 6 sprints. Les tests Playwright valident les parcours utilisateur. La recette manuelle se concentre sur le design et les fonctionnalités e-commerce. Personne ne vérifie systématiquement les meta tags en SSR.
Au déploiement, trois problèmes passent en production :
-
Le composant
<Head>d'une dizaine de pages catégorie "soldes" utilise unuseEffectpour le title — résultat : le title SSR est le fallback générique "Acme Store" au lieu de "Soldes Chaussures Running | Acme Store". Ces pages représentent 8% du trafic organique en période de soldes. -
Le middleware d'authentification ajoute un header
X-Robots-Tag: noindexsur toutes les pages quand un cookie de session existe. En staging, le QA testait toujours connecté. Le header ne part pas sur le HTML mais sur la réponse HTTP — invisible dans le DOM, mais bien lu par Googlebot. C'est le type d'erreur que même l'inspection manuelle du HTML ne détecte pas. -
Les pages à navigation facettée (
/categories/chaussures?color=red) perdent leur balise canonical vers la page catégorie parente. Le composant canonical est lié au router Next.js mais ne gère pas les query params. 420 catégories × une moyenne de 12 combinaisons de facettes = environ 5 000 URLs potentiellement sans canonical correct, un risque majeur de contenu dupliqué et de dilution du crawl budget.
Avec l'intégration CI/CD
Le même scénario, mais avec les checks SEO dans le pipeline.
Le script statique aurait détecté le problème (1) si les pages étaient pré-rendues. Comme c'est du SSR, c'est le script dynamique qui le capture : le expectedTitle de la page /categories/soldes-chaussures-running ne match pas le regex attendu.
Le problème (2) nécessite un check supplémentaire — vérifier les headers HTTP, pas juste le HTML :
// Ajout dans checkPage()
const xRobotsTag = response.headers.get('x-robots-tag') || '';
if (page.shouldBeIndexable && xRobotsTag.includes('noindex')) {
errors.push(`${page.url}: X-Robots-Tag header contains noindex`);
}
Ce check de 3 lignes aurait bloqué le déploiement. Trois lignes pour éviter une désindexation potentielle de l'ensemble du site pour les utilisateurs connectés (Googlebot peut avoir un cookie si votre consent banner en pose un par défaut — c'est un cas réel documenté).
Le problème (3) est attrapé par le check dynamique si vous incluez une URL facettée dans votre échantillon avec shouldBeIndexable: false et un expectedCanonical pointant vers la catégorie parente.
Impact mesuré
L'équipe estime que sans les checks CI/CD, les problèmes (1), (2) et (3) auraient coexisté en production pendant 5 à 10 jours — le temps qu'un audit SEO soit déclenché ou qu'une alerte de trafic se déclenche dans Google Search Console (dont le reporting a un délai inhérent). Sur un site à 185 000 sessions organiques/mois, un impact de 15-20% pendant 10 jours représente 900 à 1 200 sessions perdues — sans compter le temps de re-crawl et de récupération de positions, qui peut prendre 2 à 4 semaines.
Aller plus loin : Lighthouse CI et les Core Web Vitals
Les checks SEO "meta/indexabilité" sont le socle. Mais la performance est aussi un signal de ranking, et elle peut régresser à chaque déploiement. Lighthouse CI permet d'intégrer des audits Lighthouse dans votre pipeline.
# lighthouserc.yml
ci:
collect:
url:
- http://localhost:3000/
- http://localhost:3000/categories/chaussures-running
- http://localhost:3000/produit/nike-pegasus-41
numberOfRuns: 3
startServerCommand: npm run start
assert:
assertions:
categories:performance:
- error
- minScore: 0.7
categories:seo:
- error
- minScore: 0.9
categories:accessibility:
- warn
- minScore: 0.8
# Assertions spécifiques
is-crawlable:
- error
- minScore: 1
document-title:
- error
- minScore: 1
meta-description:
- error
- minScore: 1
canonical:
- error
- minScore: 1
hreflang:
- warn
- minScore: 1
upload:
target: filesystem
outputDir: ./lighthouse-results
Le scoring SEO de Lighthouse est basique — il vérifie les mêmes fondamentaux que notre script custom. L'intérêt réside dans la combinaison avec le scoring performance. Si un déploiement fait chuter le score performance de 0.85 à 0.60 (parce qu'un développeur a ajouté un bundle de 400KB de polyfills), le pipeline le bloque.
Un piège courant : mettre des seuils trop agressifs. Un minScore: 0.95 sur la performance va faire échouer le pipeline sur des fluctuations normales de Lighthouse (le score varie de ±3-5 points entre les runs, même sur la même page). D'où le numberOfRuns: 3 — Lighthouse CI prend la médiane. Restez à 0.7-0.8 sur la performance et montez progressivement.
Valider le sitemap et le robots.txt dans le pipeline
Le sitemap.xml et le robots.txt sont des fichiers critiques qui changent rarement — mais quand ils cassent, l'impact est massif. Un sitemap malformé empêche la découverte de nouvelles pages. Un robots.txt avec un Disallow: / accidentel bloque l'indexation de tout le site.
Ajoutez ces validations à votre pipeline :
// scripts/check-sitemap-robots.ts
async function checkRobotsTxt(baseUrl: string): Promise<string[]> {
const errors: string[] = [];
const res = await fetch(`${baseUrl}/robots.txt`);
if (res.status !== 200) {
errors.push(`robots.txt returned ${res.status}`);
return errors;
}
const content = await res.text();
// Vérifier qu'il n'y a pas de Disallow global accidentel
const lines = content.split('\n').map(l => l.trim().toLowerCase());
const hasGlobalDisallow = lines.some(l =>
l === 'disallow: /' &&
!lines.some(prev => prev.startsWith('user-agent:') && prev !== 'user-agent: *')
);
// Heuristique : si Disallow: / est sous User-agent: *, c'est probablement une erreur
if (content.toLowerCase().includes('user-agent: *') &&
content.toLowerCase().includes('disallow: /') &&
!content.toLowerCase().includes('disallow: /admin')) {
// Disallow: / sous User-agent: * et ce n'est pas juste /admin ou un path spécifique
const userAgentBlock = content.split(/user-agent:\s*\*/i)[1]?.split(/user-agent:/i)[0] || '';
if (userAgentBlock.match(/disallow:\s*\/\s*$/m)) {
errors.push('robots.txt has "Disallow: /" under "User-agent: *" — this blocks all crawling');
}
}
// Vérifier la présence du sitemap
if (!content.toLowerCase().includes('sitemap:')) {
errors.push('robots.txt does not reference a sitemap');
}
return errors;
}
async function checkSitemap(baseUrl: string): Promise<string[]> {
const errors: string[] = [];
const res = await fetch(`${baseUrl}/sitemap.xml`);
if (res.status !== 200) {
errors.push(`sitemap.xml returned ${res.status}`);
return errors;
}
const content = await res.text();
// Vérification basique de la structure XML
if (!content.includes('<?xml') || !content.includes('<urlset') && !content.includes('<sitemapindex')) {
errors.push('sitemap.xml does not appear to be valid XML');
return errors;
}
// Extraire les URLs et en vérifier un échantillon
const urlMatches = content.match(/<loc>(.*?)<\/loc>/g) || [];
const urls = urlMatches.map(m => m.replace(/<\/?loc>/g, ''));
if (urls.length === 0) {
errors.push('sitemap.xml contains no URLs');
return errors;
}
console.log(` Sitemap contains ${urls.length} URLs. Sampling 10 for validation...`);
// Vérifier 10 URLs aléatoires
const sample = urls.sort(() => Math.random() - 0.5).slice(0, 10);
for (const url of sample) {
try {
const pageRes = await fetch(url, { method: 'HEAD', redirect: 'manual' });
if (pageRes.status !== 200) {
errors.push(`Sitemap URL ${url} returned ${pageRes.status}`);
}
} catch (e) {
errors.push(`Sitemap URL ${url} is unreachable`);
}
}
return errors;
}
async function main() {
const baseUrl = process.env.PREVIEW_URL || 'http://localhost:3000';
const allErrors: string[] = [];
const robotsErrors = await checkRobotsTxt(baseUrl);
allErrors.push(...robotsErrors);
const sitemapErrors = await checkSitemap(baseUrl);
allErrors.push(...sitemapErrors);
if (allErrors.length > 0) {
console.error(`\n❌ ${allErrors.length} sitemap/robots issues:\n`);
allErrors.forEach(e => console.error(` ${e}`));
process.exit(1);
}
console.log('\n✅ robots.txt and sitemap.xml checks passed.');
}
main();
Le sampling aléatoire de 10 URLs du sitemap est un compromis pragmatique. Vérifier les 15 000 URLs prendrait trop longtemps pour un pipeline CI. Mais 10 URLs aléatoires ont une forte probabilité de détecter un problème systémique (mauvaise base URL, pattern de route cassé, certificat SSL expiré sur le domaine canonical).
Trade-offs et limites de l'approche
L'automatisation des checks SEO dans le CI/CD n'est pas une solution miracle. Plusieurs limites méritent d'être comprises.
Ce que le CI/CD ne peut pas détecter
Les problèmes de contenu. Un title techniquement présent mais sémantiquement mauvais ("Product Page | Acme" au lieu de "Nike Pegasus 41 - Chaussures Running Homme | Acme") passe les checks. Vous pouvez ajouter des regex pour vérifier que le title contient le nom du produit, mais la frontière entre check technique et check éditorial est floue.
Les problèmes de crawl en production. Le comportement de Googlebot dépend de facteurs que votre environnement de preview ne reproduit pas : la latence réseau, le comportement de cache, la charge serveur, les CDN. Un pipeline CI ne peut pas simuler le crawl réel de Google. Pour ça, vous avez besoin de l'analyse de logs.
Les régressions progressives. Un déploiement du vendredi soir qui casse une meta est détectable. Mais une dégradation lente — une catégorie dont le trafic baisse de 3% par semaine parce que le maillage interne s'est dilué — ne se voit pas dans un check de pipeline. C'est le domaine du monitoring continu.
Le coût de maintenance
Chaque nouveau template, chaque nouvelle route, chaque changement de structure URL nécessite une mise à jour de la liste PAGES_TO_CHECK. Si cette liste n'est pas maintenue, les checks deviennent un faux sentiment de sécurité — vous testez des pages qui n'existent plus et vous ignorez les nouvelles.
La solution : générer dynamiquement la liste à partir du sitemap ou du routing de l'application. Dans Next.js avec App Router, vous pouvez parser le filesystem app/ pour extraire les routes et générer un échantillon automatique par template.
Checks bloquants vs. avertissements
Tout ne doit pas bloquer le pipeline. Une meta description manquante sur une page de mentions légales ne justifie pas de bloquer un hotfix critique. Implémentez deux niveaux de sévérité :
- Error (bloquant) : title manquant, noindex accidentel sur une page stratégique, robots.txt qui bloque le crawl, SSR cassé. Le pipeline s'arrête.
- Warning (non-bloquant) : meta description courte, title trop long, H1 manquant sur une page secondaire. Le pipeline continue mais le warning est visible dans le rapport.
La clé est que les checks bloquants doivent avoir un taux de faux positifs quasi nul. Si le pipeline bloque à tort une fois par semaine, les développeurs vont ajouter --skip-seo-checks dans leurs commandes, et tout le système perd sa valeur.
Articuler CI/CD et monitoring continu
Les checks CI/CD sont une première ligne de défense — ils attrapent les régressions avant qu'elles n'atteignent la production. Mais ils ne couvrent qu'un échantillon de pages, dans un environnement contrôlé, au moment du déploiement.
Le monitoring continu opère en complément : il crawle le site en production, à intervalles réguliers, sur l'ensemble des pages, et détecte les problèmes que le CI/CD ne peut pas voir. Un outil comme Seogard détecte automatiquement qu'une balise canonical a changé, qu'un groupe de pages a