zéro ligne de javascript pour un crawler : les 1 416 octets de html que bing lit à la place de mon app react
dev · Oct 4, 2026 · 6 min read
ce site est une app react rendue côté client. elle livre un seul fichier html. il y a 539 urls dans la sitemap et chacune est servie par ce même document, parce qu'il n'y a pas de /blog/index.html et pas de /it/index.html — il y a exactement un fichier html dans la sortie de build, et il fait 12 245 octets.
alors que voit bing ?
un crawler qui n'exécute pas de javascript regarde ce document et trouve un <head> parfaitement correct et une <div id="root"> vide. 10 377 octets de métadonnées et zéro mot de contenu.
le correctif a été d'arrêter de livrer une div vide.
le repli
33 lignes de html brut dans #root, que react jette au montage :
| mesure | valeur |
|---|---|
| lignes | 33 |
| octets | 1 416 |
| mots | 101 |
<h1> / <h2> / <li> |
1 / 2 / 7 |
| liens | 7 |
balises <script> |
0 |
un h1, un court paragraphe qui dit qui je suis et ce que je fais, une liste « services », une liste « contact », et quatre liens dans le site. 1 416 octets de copy marketing que je maintiens à la main, pour toujours, pour un lecteur qui ne les regardera jamais.
plus 6 règles css — 13 lignes, 543 octets — pour qu'il arrive à avoir l'air d'une petite page plutôt que d'une page cassée. cette partie n'est pas de la décoration. un repli rendu en texte noir sur blanc sans style se lit, pour un relecteur humain, comme ce site est cassé, et les mêmes 543 octets font la différence entre ce site a un contenu minimal et ce site est cassé.
pourquoi ce n'est pas un doublon de la page
parce que createRoot vide son conteneur. voici tout ce que fait le code de montage :
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
react ne fusionne pas dans #root, il efface les enfants et met les siens. donc le visiteur ne voit jamais le repli plus de quelques millisecondes, et le crawler ne voit jamais react. il n'y a pas de désaccord d'hydratation à craindre et aucun état « l'app est-elle prête » à concevoir autour.
le compromis, énoncé clairement — puis corrigé
au départ je ne faisais pas de prerender. astro, ou next avec export statique, ou vite-ssg aurait donné à chaque url son propre html avec son propre texte. je ne l'ai pas fait, parce que cela transforme une page marketing en une deuxième chaîne de build avec son propre cache et son propre mode de défaillance de déploiement.
puis j'ai mesuré ce que le plancher coûtait réellement, et la réponse était pire que ce que j'avais supposé : 374 urls, et chacune était le même document. le canonical sur les 374 disait la racine du site, ce qui revient à dire à un crawler que les 373 autres sont des doublons de la page d'accueil. un document, 374 adresses.
alors j'ai écrit un prerendeur à la place. il produit un fichier html par couple (route, locale) dans la sortie de build, généré depuis les mêmes données que lit l'app, pour que les deux ne puissent pas être en désaccord. pnpm run build écrit maintenant 189 documents — 7 routes de page × 7 locales, et un document par billet et par locale dans laquelle ce billet a été traduit. une locale apporte une url dès que ce billet est écrit, et la grappe hreflang d’un billet est calculée à partir de son propre id, donc le document et ses alternates ne peuvent jamais être en désaccord sur les langues qui existent.
voici le compte honnête, par url, maintenant :
| quoi | spécifique à la route ? |
|---|---|
<title>, description, canonical, hreflang |
oui — statiquement dans le head, un par url |
| JSON-LD | oui — BlogPosting et BreadcrumbList sur les billets, WebPage ailleurs |
| sitemap, rss | oui — 35 routes, réparties sur les deux grappes de locales |
| texte du corps | oui sur les billets et sur l'index du blog — l'article entier, dans cette langue |
texte du corps sur /lab, /now, /lens |
partiellement. titre, description et navigation, mais pas la prose, parce que cette prose vit dans du jsx et qu'il n'y a pas de fichier de données pour la lire |
| l'app | oui, un bundle javascript |
la partie intéressante n'était pas d'écrire le générateur. c'était de découvrir que la grappe hreflang devait être par type de route. un billet existe en trois langues et une page en sept, donc une liste unique codée en dur de onze annonçait six langues qui servaient l'original anglais sous une étiquette de langue étrangère — ce que google compte comme contenu dupliqué, pas comme localisation.
les deux listes sont maintenant dérivées des fichiers de modules sur le disque. déposer posts/fr.ts fait apparaître le français, et rien d'autre n'a besoin d'être changé.
le plancher a disparu pour les billets. il est encore un plancher pour trois pages, et je préfère le dire que de prétendre le contraire.
la couche au-dessus du repli, et les deux bugs qu'elle contient
le head n'est pas la seule chose spécifique à la route. /blog/* a une cloudflare pages function qui réécrit les métadonnées pour les bots connus :
const BOT_AGENTS = [
"Twitterbot", "facebookexternalhit", "LinkedInBot", "Slackbot",
"TelegramBot", "WhatsApp", "Discordbot", "discordbot",
"ia_archiver", "Googlebot", "bingbot", "Applebot",
];
douze user agents. pour ceux-là, [id].js cherche le billet dans un og-data.js généré, puis réécrit <title>, og:title, og:description, og:url, og:type, les balises twitter et la meta description via HTMLRewriter. bingbot est dans la liste, et bingbot est celui qui n'exécute pas de javascript. donc pour un billet de blog, bing reçoit le vrai titre du vrai billet.
et il reçoit ensuite les 101 mots de la page d'accueil en dessous.
bug 1 : DuckDuckDuckBot n'est pas dans cette liste. douze entrées, et le crawler que j'ai nommé dans le commentaire juste au-dessus du bloc de repli — la raison même pour laquelle le repli existe — est celui qui retombe sur les valeurs par défaut. aux yeux de duckduckgo, tous les billets du site s'appellent Enea | Blog. une seule chaîne. c'est tout le correctif et je ne l'ai pas appliqué.
bug 2 : le canonical n'est jamais réécrit. la map dans MetaRewriter inclut og:url mais il n'y a pas de gestionnaire pour link[rel=canonical]. donc le même document dit aux plateformes sociales « cette url est /blog/mon-billet » et dit aux moteurs de recherche « cette url est https://eneawork.it/ ». og:url et rel=canonical n'ont pas le droit de se contredire, et les miens le font, sur les 42 billets. le correctif est une entrée de plus dans la map et un gestionnaire .on("link[rel=canonical]") de plus.
plus petit : la fonction réécrit twitter:card en summary_large_image pendant que og:image reste la photo de profil 460×460, parce que tous les billets partagent une seule image. une grande carte avec un avatar carré dedans.
ce sur quoi je ne vais pas faire semblant
le repli est de la copy marketing qui vit dans un chemin de code. le jour où le site en production change et où j'oublie de la changer, on sert au crawler une description périmée d'un autre site, et rien ne casse. aucun test ne casse, aucune erreur de type, aucune erreur de build. c'est le genre de dette qui n'apparaît que dans du trafic que tu ne peux pas attribuer.
j'ai déjà laissé un petit fossile dedans : la ligne se lit Fotografia: <a href="/lens">lens</a> — un mot italien dans un document par ailleurs anglais, dans le seul morceau de html de ce site qui est censé être ma version permanente. ce n'est pas un bug. c'est à quoi ressemble de la copy maintenance à la main après un an.
ce que je dirais à mon moi passé
- le head est là où vit le seo, et le csr n'y touche pas. chaque morceau de métadonnées qui compte est statique, dans le document, avant qu'aucun javascript ne s'exécute. si vous écrivez le head à la main, vous avez déjà fait 80% du travail seo d'un site statique, et les 20% restants sont ce dont les gens parlent quand ils disent « vous devriez faire du prerender ».
- un repli doit être la plus courte chose qui reste vraie. 1 416 octets, c'est un paragraphe. 1 416 octets de contenu par route, c'est 539 documents. le premier est un week-end ; le second est une plateforme.
- quand vous écrivez un commentaire qui nomme une menace, vérifiez que vous l'avez réellement traitée. j'ai écrit « bing, duckduckgo et les crawlers ia qui n'exécutent pas de javascript » trois lignes au-dessus d'un repli, puis j'ai livré une liste blanche de douze bots dont le deuxième nom manquait. le commentaire était plus rigoureux que le code. il l'est toujours.