0 righe di javascript per un crawler: i 1.416 byte di html che bing legge al posto della mia app react

dev · Oct 4, 2026 · 6 min read

questo sito è una app react renderizzata lato client. pubblica un file html. nella sitemap ci sono 539 url e ognuno di essi riceve lo stesso identico documento, perché non c'è /blog/index.html né /it/index.html — c'è esattamente un file html nell'output del build e pesa 12.245 byte.

allora cosa vede bing?

un crawler che non esegue javascript guarda quel documento e trova un <head> perfettamente valido e un <div id="root"> vuoto. 10.377 byte di metadati e zero parole di contenuto.

la correzione è stata smettere di pubblicare un div vuoto.

il fallback

33 righe di html puro dentro #root, che react butta via al mount:

misura valore
righe 33
byte 1.416
parole 101
<h1> / <h2> / <li> 1 / 2 / 7
link 7
tag <script> 0

un h1, un paragrafo breve che dice chi sono e cosa faccio, una lista di «servizi», una lista di «contatti», e quattro link dentro il sito. 1.416 byte di copy di marketing che mantengo a mano, per sempre, per un lettore che non li guarderà mai.

più 6 regole css — 13 righe, 543 byte — così arriva con l'aspetto di una piccola pagina invece che di una rotta. quella parte non è decorazione. un fallback renderizzato come testo nero su bianco senza stile, a un revisore umano dice questo sito è rotto, e gli stessi 543 byte sono la differenza fra questo sito ha contenuti minimi e questo sito è rotto.

perché non è un duplicato della pagina

perché createRoot svuota il suo contenitore. questo è tutto il codice di mount:

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <ThemeProvider>
      <App />

react non fa merge dentro #root, cancella i figli e mette i suoi. quindi il visitatore non vede mai il fallback per più di qualche millisecondo, e il crawler non vede mai react. non c'è nessun mismatch di idratazione di cui preoccuparsi e nessuno stato «l'app è già pronta?» da progettare intorno.

il compromesso, detto chiaramente

all'inizio non ho fatto il prerender. astro, o next con export statico, o vite-ssg avrebbero dato a ogni url il proprio html con il proprio testo. non l'ho presa, perché trasforma una pagina di marketing in una seconda pipeline di build con la sua cache e il suo modo di fallire in deploy.

poi sono andato a misurare quanto costava davvero quel pavimento, e la risposta era peggio di quanto pensassi: 374 url, e ognuno era lo stesso documento. la canonical su tutti e 374 diceva la root del sito, che è un modo di dire al crawler che gli altri 373 sono duplicati della home. un documento, 374 indirizzi.

così ho scritto un prerender invece. genera un file html per ogni coppia (rotta, lingua) dentro l'output del build, a partire dagli stessi dati che legge l'app, così i due non possono divergere. pnpm run build ora scrive 189 documenti — 7 rotte di pagina × 7 lingue, e un documento per ogni post per ogni lingua in cui quel post è stato tradotto. una lingua contribuisce un url appena quel post è scritto, e la griglia hreflang di un post è calcolata dal suo stesso id, quindi il documento e i suoi alternates non possono mai discordare su quali lingue esistono.

ecco il conto onesto, per url, adesso:

cosa basta la rotta?
<title>, description, canonical, hreflang sì — statico nell'head, uno per url
JSON-LD sì — BlogPosting e BreadcrumbList sui post, WebPage altrove
sitemap, rss sì — 35 rotte, divise sui due cluster di lingue
testo del body sì sui post e sull'indice del blog — l'articolo intero, in quella lingua
testo del body su /lab, /now, /lens in parte. titolo, descrizione e navigazione, ma non la prosa, perché quella prosa sta nel jsx e non c'è un file di dati da cui leggerla
l'app sì, un unico bundle javascript

la parte interessante non è stato scrivere il generatore. è stato scoprire che il cluster hreflang doveva essere per tipo di rotta. un post esiste in tre lingue e una pagina in sette, quindi una lista fissa di undici stava pubblicizzando sei lingue che servivano l'originale inglese sotto un'etichetta straniera — che google conta come contenuto duplicato, non come localizzazione.

entrambe le liste ora derivano dai file di modulo sul disco. buttare dentro posts/fr.ts fa comparire il francese, e non c'è altro da cambiare.

il pavimento è sparito per i post. per tre pagine è ancora un pavimento, e preferisco dirlo piuttosto fingere il contrario.

quello che ho preso invece è una soglia. un crawler trova qualcosa su cui appoggiarsi invece del nulla. ecco il bilancio onesto di cosa è vero e cosa è falso, per url:

cosa dipende dalla rotta?
<title>, description, canonical, hreflang statici nell'head, e sì — uno per url
JSON-LD (Person, WebSite, ProfilePage) sì
sitemap, rss sì, 35 rotte, sui due cluster di lingue
testo del body no. è la homepage, su ognuno di essi
l'app sì, un bundle javascript

l'head è reale. il body è una soglia. per un sito personale è un compromesso con cui posso convivere; per un sito il cui prodotto è il testo del body non lo è, e lo stesso codice sarebbe un errore.

il layer sopra il fallback, e i due bug che contiene

l'head non è l'unica cosa che dipende dalla rotta. /blog/* ha una cloudflare pages function che riscrive i metadati per i bot noti:

const BOT_AGENTS = [
  "Twitterbot", "facebookexternalhit", "LinkedInBot", "Slackbot",
  "TelegramBot", "WhatsApp", "Discordbot", "discordbot",
  "ia_archiver", "Googlebot", "bingbot", "Applebot",
];

dodici user agent. per quelli, [id].js cerca il post in un og-data.js generato, poi riscrive <title>, og:title, og:description, og:url, og:type, i tag twitter e la meta description con HTMLRewriter. bingbot è nella lista, e bingbot è quello che non esegue javascript. quindi per un post del blog, bing riceve il titolo vero del post vero.

e poi riceve sotto le 101 parole della homepage.

bug 1: DuckDuckDuckBot non è in quella lista. dodici voci, e il crawler che ho nominato nel commento immediatamente sopra il blocco fallback — l'unica ragione per cui il fallback esiste — è proprio quello che cade sui valori predefiniti. ogni post del sito si chiama Enea | Blog per quanto riguarda duckduckgo. una stringa. questa è l'intera correzione e non l'ho applicata.

bug 2: il canonical non viene mai riscritto. la mappa in MetaRewriter include og:url ma non c'è handler per link[rel=canonical]. quindi lo stesso documento dice alle piattaforme sociali «questo url è /blog/mio-post» e dice ai motori «questo url è https://eneawork.it/». og:url e rel=canonical non possono discordare, e i miei discordano, su tutti i 42 post. la correzione è una voce in più nella mappa e un handler .on("link[rel=canonical]") in più.

più piccolo: la function riscrive twitter:card a summary_large_image mentre og:image resta la foto profilo 460×460, perché ogni post condivide un'immagine. una card grande con dentro un avatar quadrato.

su cosa non fingerò

il fallback è copy di marketing seduta dentro un percorso di codice. nel momento in cui il sito live cambia e io dimentico di cambiarla, il crawler riceve una descrizione obsoleta di un altro sito, e niente fallisce. salta un test, nessun errore di tipo, nessun errore di build. è il tipo di debito che compare solo in traffico che non puoi attribuire.

ci ho già lasciato dentro un piccolo fossile: la riga dice Fotografia: <a href="/lens">lens</a> — una parola italiana in un documento altrimenti inglese, nell'unico pezzo di html di questo sito che dovrebbe essere la versione permanente di me. non è un bug. è com'è fatto il copy mantenuto a mano dopo un anno.

cosa direi al me di ieri

  • l'head è dove vive il seo, e la csr non lo tocca. ogni pezzo di metadato che conta è statico, nel documento, prima che giri qualunque javascript. se scrivi l'head a mano hai già fatto l'80% del lavoro seo di un sito statico, e il 20% restante è la cosa che intendono quando dicono «dovresti fare il prerender».
  • un fallback deve essere la cosa più breve che sia ancora vera. 1.416 byte sono un paragrafo. 1.416 byte di contenuto per rotta sono 539 documenti. il primo è un weekend; il secondo è una piattaforma.
  • quando scrivi un commento che nomina una minaccia, verifica di averla davvero gestita. ho scritto «bing, duckduckgo e i crawler ai che non eseguono javascript» tre righe sopra un fallback, e poi ho pubblicato un allowlist di dodici bot con il secondo nome mancante. il commento era più accurato del codice. di solito lo è.