SEO d'un blog PHP : JSON-LD, ToC et pagination crawlable sans framework

Google Search Console affichait zéro article indexé après trois semaines de publication. Les articles existaient. Les URLs répondaient en 200. Le sitemap était soumis. Le problème était ailleurs : le listing du blog chargeait posts.json via fetch(), puis rendait tout le HTML en JavaScript. Pour Googlebot, la page était une coquille vide avec un spinner.

Ce que je décris ici, c'est l'ensemble des corrections appliquées au template PHP du blog — pas de framework, pas de build, Apache pur. Chaque point a une raison précise, pas juste "parce que les bonnes pratiques SEO disent de le faire".

Le problème JS-first

Googlebot peut exécuter JavaScript. Google le dit depuis des années, et c'est vrai. Mais "peut" ne veut pas dire "systématiquement" ni "immédiatement". Le crawl JS passe par une file de rendu secondaire — les pages HTML brutes sont indexées en priorité. Résultat : un blog full-JS peut mettre des semaines à être indexé, et encore, seulement si Googlebot juge que la page vaut le coût d'un rendu.

Le deuxième problème est structurel : si la pagination est gérée entièrement en JS (clic sur "Page 2" → rechargement du tableau côté client), la page 2 n'existe pas du point de vue d'un crawler. Il n'y a qu'une seule URL, et elle affiche toujours les 10 premiers articles. Googlebot ne clique pas sur des boutons JavaScript pour voir la suite. Les articles en page 3 ou 4 ne seront jamais crawlés.

La correction est simple en principe : le serveur doit rendre le HTML des articles pour la page courante. Le JavaScript peut ensuite prendre la main pour la navigation interactive — mais le premier rendu doit être visible dans le source.

JSON-LD et articleBody : ce que Google veut vraiment

Schema.org BlogPosting est la donnée structurée de base pour un article de blog. Elle permet à Google d'afficher la date, l'auteur, et potentiellement le breadcrumb dans les résultats enrichis. Mais le champ le plus utile est articleBody : c'est le texte brut de l'article, que Google utilise pour les featured snippets.

Le problème : quand le JSON-LD est généré dans blog_header() (en début de page), le contenu de l'article n'a pas encore été rendu. On ne peut pas injecter articleBody à ce moment-là.

La solution est de passer par l'output buffering PHP. Dans blog_header(), on démarre un buffer et on pose un placeholder dans le JSON-LD :

ob_start();
// JSON-LD avec placeholder
$json_ld = '{ "@type": "BlogPosting", "articleBody": "SommaireLe problème JS-firstJSON-LD et articleBody : ce que Google veut vraimentLe ToC PHP et le piège iconvPagination crawlable : PHP hybridehreflang, canonical, og:type : les détails qui comptentConclusion Google Search Console affichait zéro article indexé après trois semaines de publication. Les articles existaient. Les URLs répondaient en 200. Le sitemap était soumis. Le problème était ailleurs : le listing du blog chargeait posts.json via fetch(), puis rendait tout le HTML en JavaScript. Pour Googlebot, la page était une coquille vide avec un spinner. Ce que je décris ici, c\'est l\'ensemble des corrections appliquées au template PHP du blog — pas de framework, pas de build, Apache pur. Chaque point a une raison précise, pas juste \"parce que les bonnes pratiques SEO disent de le faire\". Le problème JS-first Googlebot peut exécuter JavaScript. Google le dit depuis des années, et c\'est vrai. Mais \"peut\" ne veut pas dire \"systématiquement\" ni \"immédiatement\". Le crawl JS passe par une file de rendu secondaire — les pages HTML brutes sont indexées en priorité. Résultat : un blog full-JS peut mettre des semaines à être indexé, et encore, seulement si Googlebot juge que la page vaut le coût d\'un rendu. Le deuxième problème est structurel : si la pagination est gérée entièrement en JS (clic sur \"Page 2\" → rechargement du tableau côté client), la page 2 n\'existe pas du point de vue d\'un crawler. Il n\'y a qu\'une seule URL, et elle affiche toujours les 10 premiers articles. Googlebot ne clique pas sur des boutons JavaScript pour voir la suite. Les articles en page 3 ou 4 ne seront jamais crawlés. La correction est simple en principe : le serveur doit rendre le HTML des articles pour la page courante. Le JavaScript peut ensuite prendre la main pour la navigation interactive — mais le premier rendu doit être visible dans le source. JSON-LD et articleBody : ce que Google veut vraiment Schema.org BlogPosting est la donnée structurée de base pour un article de blog. Elle permet à Google d\'afficher la date, l\'auteur, et potentiellement le breadcrumb dans les résultats enrichis. Mais le champ le plus utile est articleBody : c\'est le texte brut de l\'article, que Google utilise pour les featured snippets. Le problème : quand le JSON-LD est généré dans blog_header() (en début de page), le contenu de l\'article n\'a pas encore été rendu. On ne peut pas injecter articleBody à ce moment-là. La solution est de passer par l\'output buffering PHP. Dans blog_header(), on démarre un buffer et on pose un placeholder dans le JSON-LD : ob_start(); // JSON-LD avec placeholder $json_ld = \'{ \"@type\": \"BlogPosting\", \"articleBody\": \"ARTICLE_BODY_PLACEHOLDER\", ... }\'; Ensuite, dans blog_footer(), on récupère tout le HTML rendu, on extrait le contenu de la div .article-content, on strip les balises, et on injecte le texte à la place du placeholder avant d\'envoyer le tout au navigateur : // Dans blog_footer() — capturer le HTML rendu par l\'article $article_html = ob_get_clean(); if (preg_match(\'/(.*)/s\', $article_html, $body_match)) { $article_body = strip_tags($body_match[1]); $article_body = preg_replace(\'/\\s+/\', \' \', trim($article_body)); $article_body = substr($article_body, 0, 5000); $json_ld = str_replace(\'\"ARTICLE_BODY_PLACEHOLDER\"\', json_encode($article_body), $json_ld); } echo $article_html; Le JSON-LD complet est injecté dans <head> juste avant que le buffer soit envoyé. Google voit un articleBody réel, pas un placeholder. La limite à 5000 caractères est arbitraire — Google tronque de toute façon. Le ToC PHP et le piège iconv Une table des matières générée côté serveur a deux avantages : elle est visible dans le source (donc crawlable), et elle ne dépend d\'aucune bibliothèque JS. L\'implémentation est simple — on parse les <h2> et <h3> du HTML de l\'article, on génère des ancres, et on injecte le ToC au début de .article-content. Le piège classique est la génération des IDs d\'ancre pour les titres accentués. Le réflexe habituel est d\'utiliser iconv pour translittérer : // ❌ Dépend de la locale système — vide sur les serveurs sans fr_FR.UTF-8 $id = preg_replace(\'/[^a-z0-9]+/\', \'-\', strtolower(iconv(\'UTF-8\', \'ASCII//TRANSLIT\', $heading_text))); Sur un serveur sans la locale fr_FR.UTF-8 installée, iconv avec //TRANSLIT retourne une chaîne vide pour les caractères accentués. L\'ancre générée est #--- au lieu de #les-3-pieges. Le lien du ToC pointe dans le vide. La correction est un mapping explicite avec strtr() : // ✅ Mapping explicite, portable $id = preg_replace(\'/[^a-z0-9]+/\', \'-\', strtolower(strtr($heading_text, [ \'à\'=>\'a\',\'â\'=>\'a\',\'é\'=>\'e\',\'è\'=>\'e\',\'ê\'=>\'e\',\'ë\'=>\'e\', \'î\'=>\'i\',\'ï\'=>\'i\',\'ô\'=>\'o\',\'ù\'=>\'u\',\'û\'=>\'u\',\'ü\'=>\'u\', \'ç\'=>\'c\',\'æ\'=>\'ae\',\'œ\'=>\'oe\', ]))); Pas de dépendance à la locale. Pas de comportement imprévisible entre le serveur de dev et la prod. Le même titre génère toujours le même ", ... }';

Ensuite, dans blog_footer(), on récupère tout le HTML rendu, on extrait le contenu de la div .article-content, on strip les balises, et on injecte le texte à la place du placeholder avant d'envoyer le tout au navigateur :

// Dans blog_footer() — capturer le HTML rendu par l'article
$article_html = ob_get_clean();

if (preg_match('/
(.*)<\/article>/s', $article_html, $body_match)) { $article_body = strip_tags($body_match[1]); $article_body = preg_replace('/\s+/', ' ', trim($article_body)); $article_body = substr($article_body, 0, 5000); $json_ld = str_replace('"SommaireLe problème JS-firstJSON-LD et articleBody : ce que Google veut vraimentLe ToC PHP et le piège iconvPagination crawlable : PHP hybridehreflang, canonical, og:type : les détails qui comptentConclusion Google Search Console affichait zéro article indexé après trois semaines de publication. Les articles existaient. Les URLs répondaient en 200. Le sitemap était soumis. Le problème était ailleurs : le listing du blog chargeait posts.json via fetch(), puis rendait tout le HTML en JavaScript. Pour Googlebot, la page était une coquille vide avec un spinner. Ce que je décris ici, c\'est l\'ensemble des corrections appliquées au template PHP du blog — pas de framework, pas de build, Apache pur. Chaque point a une raison précise, pas juste \"parce que les bonnes pratiques SEO disent de le faire\". Le problème JS-first Googlebot peut exécuter JavaScript. Google le dit depuis des années, et c\'est vrai. Mais \"peut\" ne veut pas dire \"systématiquement\" ni \"immédiatement\". Le crawl JS passe par une file de rendu secondaire — les pages HTML brutes sont indexées en priorité. Résultat : un blog full-JS peut mettre des semaines à être indexé, et encore, seulement si Googlebot juge que la page vaut le coût d\'un rendu. Le deuxième problème est structurel : si la pagination est gérée entièrement en JS (clic sur \"Page 2\" → rechargement du tableau côté client), la page 2 n\'existe pas du point de vue d\'un crawler. Il n\'y a qu\'une seule URL, et elle affiche toujours les 10 premiers articles. Googlebot ne clique pas sur des boutons JavaScript pour voir la suite. Les articles en page 3 ou 4 ne seront jamais crawlés. La correction est simple en principe : le serveur doit rendre le HTML des articles pour la page courante. Le JavaScript peut ensuite prendre la main pour la navigation interactive — mais le premier rendu doit être visible dans le source. JSON-LD et articleBody : ce que Google veut vraiment Schema.org BlogPosting est la donnée structurée de base pour un article de blog. Elle permet à Google d\'afficher la date, l\'auteur, et potentiellement le breadcrumb dans les résultats enrichis. Mais le champ le plus utile est articleBody : c\'est le texte brut de l\'article, que Google utilise pour les featured snippets. Le problème : quand le JSON-LD est généré dans blog_header() (en début de page), le contenu de l\'article n\'a pas encore été rendu. On ne peut pas injecter articleBody à ce moment-là. La solution est de passer par l\'output buffering PHP. Dans blog_header(), on démarre un buffer et on pose un placeholder dans le JSON-LD : ob_start(); // JSON-LD avec placeholder $json_ld = \'{ \"@type\": \"BlogPosting\", \"articleBody\": \"ARTICLE_BODY_PLACEHOLDER\", ... }\'; Ensuite, dans blog_footer(), on récupère tout le HTML rendu, on extrait le contenu de la div .article-content, on strip les balises, et on injecte le texte à la place du placeholder avant d\'envoyer le tout au navigateur : // Dans blog_footer() — capturer le HTML rendu par l\'article $article_html = ob_get_clean(); if (preg_match(\'/(.*)/s\', $article_html, $body_match)) { $article_body = strip_tags($body_match[1]); $article_body = preg_replace(\'/\\s+/\', \' \', trim($article_body)); $article_body = substr($article_body, 0, 5000); $json_ld = str_replace(\'\"ARTICLE_BODY_PLACEHOLDER\"\', json_encode($article_body), $json_ld); } echo $article_html; Le JSON-LD complet est injecté dans <head> juste avant que le buffer soit envoyé. Google voit un articleBody réel, pas un placeholder. La limite à 5000 caractères est arbitraire — Google tronque de toute façon. Le ToC PHP et le piège iconv Une table des matières générée côté serveur a deux avantages : elle est visible dans le source (donc crawlable), et elle ne dépend d\'aucune bibliothèque JS. L\'implémentation est simple — on parse les <h2> et <h3> du HTML de l\'article, on génère des ancres, et on injecte le ToC au début de .article-content. Le piège classique est la génération des IDs d\'ancre pour les titres accentués. Le réflexe habituel est d\'utiliser iconv pour translittérer : // ❌ Dépend de la locale système — vide sur les serveurs sans fr_FR.UTF-8 $id = preg_replace(\'/[^a-z0-9]+/\', \'-\', strtolower(iconv(\'UTF-8\', \'ASCII//TRANSLIT\', $heading_text))); Sur un serveur sans la locale fr_FR.UTF-8 installée, iconv avec //TRANSLIT retourne une chaîne vide pour les caractères accentués. L\'ancre générée est #--- au lieu de #les-3-pieges. Le lien du ToC pointe dans le vide. La correction est un mapping explicite avec strtr() : // ✅ Mapping explicite, portable $id = preg_replace(\'/[^a-z0-9]+/\', \'-\', strtolower(strtr($heading_text, [ \'à\'=>\'a\',\'â\'=>\'a\',\'é\'=>\'e\',\'è\'=>\'e\',\'ê\'=>\'e\',\'ë\'=>\'e\', \'î\'=>\'i\',\'ï\'=>\'i\',\'ô\'=>\'o\',\'ù\'=>\'u\',\'û\'=>\'u\',\'ü\'=>\'u\', \'ç\'=>\'c\',\'æ\'=>\'ae\',\'œ\'=>\'oe\', ]))); Pas de dépendance à la locale. Pas de comportement imprévisible entre le serveur de dev et la prod. Le même titre génère toujours le même "', json_encode($article_body), $json_ld); } echo $article_html;

Le JSON-LD complet est injecté dans <head> juste avant que le buffer soit envoyé. Google voit un articleBody réel, pas un placeholder. La limite à 5000 caractères est arbitraire — Google tronque de toute façon.

Le ToC PHP et le piège iconv

Une table des matières générée côté serveur a deux avantages : elle est visible dans le source (donc crawlable), et elle ne dépend d'aucune bibliothèque JS. L'implémentation est simple — on parse les <h2> et <h3> du HTML de l'article, on génère des ancres, et on injecte le ToC au début de .article-content.

Le piège classique est la génération des IDs d'ancre pour les titres accentués. Le réflexe habituel est d'utiliser iconv pour translittérer :

// ❌ Dépend de la locale système — vide sur les serveurs sans fr_FR.UTF-8
$id = preg_replace('/[^a-z0-9]+/', '-', strtolower(iconv('UTF-8', 'ASCII//TRANSLIT', $heading_text)));

Sur un serveur sans la locale fr_FR.UTF-8 installée, iconv avec //TRANSLIT retourne une chaîne vide pour les caractères accentués. L'ancre générée est #--- au lieu de #les-3-pieges. Le lien du ToC pointe dans le vide.

La correction est un mapping explicite avec strtr() :

// ✅ Mapping explicite, portable
$id = preg_replace('/[^a-z0-9]+/', '-', strtolower(strtr($heading_text, [
    'à'=>'a','â'=>'a','é'=>'e','è'=>'e','ê'=>'e','ë'=>'e',
    'î'=>'i','ï'=>'i','ô'=>'o','ù'=>'u','û'=>'u','ü'=>'u',
    'ç'=>'c','æ'=>'ae','œ'=>'oe',
])));

Pas de dépendance à la locale. Pas de comportement imprévisible entre le serveur de dev et la prod. Le même titre génère toujours le même ID. Le même pattern est appliqué à la fois pour les IDs des <h2> dans le corps de l'article et pour les liens dans le ToC — cohérence garantie.

Pagination crawlable : PHP hybride

L'objectif est que Googlebot puisse atteindre tous les articles, pas seulement ceux de la première page. La contrainte : ne pas casser la navigation interactive existante (recherche, filtres par catégorie, pagination au clic).

La solution hybride : PHP charge posts.json et rend les cards pour la page courante (déterminée par le paramètre ?page=N). JavaScript conserve ses fonctions de recherche et de filtre, mais détecte si PHP a déjà rendu le contenu au chargement initial — et dans ce cas, ne re-rend pas.

<?php foreach ($page_posts as $post):
    $meta = $post[$lang];
    $url = $base_url . $post['slug'];
?>
<article class="blog-card" data-slug="<?= htmlspecialchars($post['slug']) ?>">
    <h2 class="blog-card-title">
        <a href="<?= htmlspecialchars($url) ?>"><?= htmlspecialchars($meta['title']) ?></a>
    </h2>
    ...
</article>
<?php endforeach; ?>

Les liens de pagination sont des <a href="?page=2"> réels, avec un attribut data-page pour que JavaScript les intercepte :

document.querySelectorAll('a[data-page]').forEach(function(link) {
    link.addEventListener('click', function(e) {
        e.preventDefault();
        currentPage = parseInt(this.dataset.page);
        render(filterPosts());
        window.history.pushState({}, '', '?page=' + currentPage);
    });
});

Pour éviter que JS re-rende les cards que PHP vient de rendre, on ajoute un garde au chargement initial :

// Si pas de filtre actif et que PHP a déjà rendu les articles, ne pas re-render
var hasPhpContent = document.querySelector('#posts-container article') !== null;
if (searchTerm || (activeCategory !== 'Tous' && activeCategory !== 'All') || !hasPhpContent) {
    render(filterPosts());
}

Googlebot crawle /blog/?page=2, voit les cards en HTML pur, suit les liens vers les articles. JS n'est plus un prérequis pour l'indexation. L'utilisateur, lui, ne voit aucune différence — la navigation reste instantanée.

hreflang, canonical, og:type : les détails qui comptent

Ces trois éléments sont souvent traités comme du copier-coller de template. Chacun a une raison précise.

Le canonical doit pointer vers l'URL sans query string. Sans ça, /blog/mon-article?page=1 et /blog/mon-article sont deux URLs distinctes pour Google, qui ne sait pas laquelle indexer. Une ligne suffit :

$canonical = strtok($current_url, '?');

Les liens hreflang indiquent à Google qu'il existe une version française et une version anglaise du même contenu. Sans eux, Google peut décider de montrer la mauvaise version selon la langue du visiteur. Le x-default est obligatoire — il indique quelle version afficher quand aucune locale ne correspond (typiquement un utilisateur japonais sur un blog FR/EN) :

<link rel="alternate" hreflang="fr" href="https://www.web-developpeur.com/blog/" />
<link rel="alternate" hreflang="en" href="https://www.web-developpeur.com/en/blog/" />
<link rel="alternate" hreflang="x-default" href="https://www.web-developpeur.com/blog/" />

L'og:type doit être article sur les pages d'articles, et website sur le listing et la homepage. La distinction a un impact sur la façon dont Facebook et LinkedIn génèrent la preview au partage. og:locale est complémentaire — fr_FR vs en_US selon la langue de l'article.

Conclusion

Aucune de ces modifications n'a nécessité de changer l'architecture du blog. Tout tient dans le template PHP partagé, et dans un ajustement mineur du JavaScript de la page listing. Pas de SSR framework, pas de build step, pas de CDN dédié.

Le point le plus contre-intuitif est le output buffering pour articleBody : c'est une technique des années 2000 qui résout élégamment un problème de séquençage entre le header et le footer d'un template. Idem pour le mapping strtr() — c'est moins "propre" qu'iconv, mais c'est ce qui fonctionne en prod.

Deux semaines après le déploiement, Search Console montrait les premiers articles indexés. Pas parce que Google avait soudainement décidé de mieux crawler le JS — mais parce que le HTML était enfin là, visible dès le premier octet de la réponse.

Commentaires (0)