0 zeilen javascript für einen crawler: die 1.416 bytes html, die bing statt meiner react-app liest
dev · Oct 4, 2026 · 6 min read
diese seite ist eine clientseitig gerenderte react-app. sie liefert eine html-datei. es gibt 539 urls in der sitemap und jede wird mit demselben dokument ausgeliefert, weil es kein /blog/index.html und kein /it/index.html gibt — es gibt genau eine html-datei im build-output und sie ist 12.245 bytes groß.
also was sieht bing?
ein crawler, der kein javascript ausführt, sieht sich dieses dokument an und findet einen völlig in ordnung befindlichen `<head>` und ein **leeres `<div id="root">`**. 10.377 bytes metadaten und null wörter inhalt.
die lösung war, aufzuhören, ein leeres div auszuliefern.
## der fallback
33 zeilen normales html in `#root`, die react beim mount wegwirft:
| messung | wert |
| --- | --- |
| zeilen | 33 |
| bytes | 1.416 |
| wörter | 101 |
| `<h1>` / `<h2>` / `<li>` | 1 / 2 / 7 |
| links | 7 |
| `<script>`-tags | **0** |
ein `h1`, ein kurzer absatz, der sagt, wer ich bin und was ich mache, eine „leistungen"-liste, eine „kontakt"-liste und vier links in die seite hinein. 1.416 bytes marketingtext, den ich von hand pflege, für immer, für einen leser, der ihn nie sehen wird.
dazu 6 css-regeln — 13 zeilen, 543 bytes — damit es wie eine kleine seite ankommt statt wie eine kaputte. dieser teil ist keine deko. ein fallback, der als ungestylter schwarzer text auf weißem grund daherkommt, liest sich für einen menschlichen prüfer als *diese seite ist kaputt*, und dieselben 543 bytes sind der unterschied zwischen *diese seite hat minimalen inhalt* und *diese seite ist kaputt*.
## warum es keine doppelung der seite ist
weil `createRoot` seinen container leert. das ist alles, was der mount-code tut:
```tsx
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<App />
```
react merged nicht in `#root`, es löscht die kinder und setzt seine eigenen. der besucher sieht den fallback also nie länger als ein paar millisekunden, und der crawler sieht react nie. es gibt keinen hydration-mismatch, über den man sich sorgen müsste, und keinen „ist die app schon bereit"-zustand, den man designen müsste.
## der kompromiss, klar benannt — und dann behoben
ursprünglich habe ich nicht prerendert. astro, oder next mit static export, oder vite-ssg hätten jeder url ihr eigenes html mit ihrem eigenen fließtext gegeben. ich habe es nicht genommen, weil es eine marketing-seite in eine zweite build-pipeline mit eigenem cache und eigenem deploy-fehlermodus verwandelt.
dann habe ich gemessen, was der boden tatsächlich kostete, und die antwort war schlimmer als ich angenommen hatte: **374 urls, und jede davon war dasselbe dokument.** das canonical auf allen 374 zeigte auf die wurzel der seite, was einem crawler sagt, die anderen 373 seien duplikate der home-seite. ein dokument, 374 adressen.
also habe ich stattdessen einen prerenderer geschrieben. er erzeugt **eine html-datei pro (route, locale)-paar** in den build-output, generiert aus denselben daten, die die app liest, sodass die beiden nicht widersprechen können. `pnpm run build` schreibt jetzt **189 dokumente** — 7 seiten-routen × 7 locales, und ein dokument pro post pro locale, in die dieser post übersetzt ist. eine sprache trägt eine url bei, sobald ein einziger post geschrieben ist, und der hreflang-cluster für einen post wird aus seiner eigenen id berechnet, sodass dokument und alternates nie darüber uneins sein können, welche sprachen es gibt.
hier ist die ehrliche bilanz, pro url, jetzt:
| was | routenspezifisch? |
| --- | --- |
| `<title>`, description, canonical, hreflang | ja — statisch im head, eine pro url |
| JSON-LD | ja — `BlogPosting` und `BreadcrumbList` auf posts, `WebPage` sonst |
| sitemap, rss | ja — 35 routen, auf die beiden locale-cluster verteilt |
| **fließtext** | **ja auf posts und auf der blog-index — der ganze artikel, in dieser sprache** |
| fließtext auf `/lab`, `/now`, `/lens` | **teilweise.** titel, description und navigation, aber nicht die prosa, weil diese prosa in jsx lebt und es keine datendatei gibt, sie auszulesen |
| die app | ja, ein javascript-bundle |
der interessante teil war nicht, den generator zu schreiben. es war zu entdecken, dass der hreflang-cluster **pro routenart** sein musste. ein post existiert in drei sprachen und eine seite in sieben, also war eine einzelne fest verdrahtete liste von elf dabei, sechs sprachen zu bewerben, die das englische original unter einem fremdsprachigen tag auslieferten — was google als duplikatinhalt zählt, nicht als lokalisierung.
beide listen werden jetzt aus den modul-dateien auf der platte abgeleitet. `posts/fr.ts` reinzulegen lässt französisch erscheinen, und sonst muss sich nichts ändern.
der boden ist für die posts weg. für drei seiten ist er immer noch ein boden, und das sage ich lieber, als so zu tun, als wäre es nicht so.
## die ebene über dem fallback, und die zwei bugs darin
der head ist nicht das einzige, was routenspezifisch ist. `/blog/*` hat eine cloudflare-pages-function, die die metadaten für bekannte bots umschreibt:
```js
const BOT_AGENTS = [
"Twitterbot", "facebookexternalhit", "LinkedInBot", "Slackbot",
"TelegramBot", "WhatsApp", "Discordbot", "discordbot",
"ia_archiver", "Googlebot", "bingbot", "Applebot",
];
```
zwölf user agents. für die schlägt `[id].js` den post in einer generierten `og-data.js` nach und schreibt dann `<title>`, `og:title`, `og:description`, `og:url`, `og:type`, die twitter-tags und die meta-description per `HTMLRewriter` um. `bingbot` steht in der liste, und bingbot ist derjenige, der kein javascript ausführt. für einen blogpost bekommt bing also den echten titel des echten posts.
und darunter bekommt er die 101 wörter der home-seite.
**bug 1: `DuckDuckDuckBot` steht nicht in dieser liste.** zwölf einträge, und der crawler, den ich im kommentar direkt über dem fallback-block benannt habe — der ganze grund, warum es den fallback gibt — ist derjenige, der auf die defaults durchfällt. jeder post der seite heißt für duckduckgo `Enea | Blog`. ein einziger string. das ist die gesamte behebung und ich habe sie nicht angewandt.
**bug 2: canonical wird nie umgeschrieben.** die mappe in `MetaRewriter` enthält `og:url`, aber es gibt keinen handler für `link[rel=canonical]`. also sagt dasselbe dokument den sozialen plattformen *„diese url ist `/blog/mein-post`"* und den suchmaschinen *„diese url ist `https://eneawork.it/`"*. `og:url` und `rel=canonical` dürfen nicht auseinandergehen, und meine gehen es, auf allen 42 posts. die behebung ist ein weiterer eintrag in der mappe und ein weiterer `.on("link[rel=canonical]")`-handler.
kleiner: die funktion schreibt `twitter:card` auf `summary_large_image`, während `og:image` das 460×460-profilbild bleibt, weil jeder post ein bild teilt. eine große karte mit einem quadratischen avatar darin.
## was ich hier nicht behaupten werde
der fallback ist **marketingtext, der in einem codepfad sitzt.** in dem augenblick, in dem sich die live-seite ändert und ich es vergesse, bekommt der crawler eine veraltete beschreibung einer anderen seite serviert, und nichts schlägt fehl. kein test bricht, kein typfehler, kein build-fehler. es ist die art von schuld, die einem nur im verkehr zeigt, den man nicht zuordnen kann.
ich habe ihm schon eine kleine fossilie hinterlassen: die zeile lautet `Fotografia: <a href="/lens">lens</a>` — ein italienisches wort in einem sonst englischen dokument, in dem einen stück html dieser seite, das die dauerhafte version von mir sein soll. es ist kein bug. es ist so, wie handgepflegter text nach einem jahr aussieht.
## was ich meinem vergangenen ich sagen würde
* **im head liegt die seo, und csr fasst ihn nicht an.** jedes stück metadaten, das zählt, ist statisch, im dokument, bevor irgendein javascript läuft. wenn du den head von hand schreibst, hast du bereits 80% der seo-arbeit einer statischen seite erledigt, und die restlichen 20% sind das, was die leute meinen, wenn sie sagen „du solltest prerendern".
* **ein fallback muss das kürzeste sein, das noch wahr ist.** 1.416 bytes sind ein absatz. 1.416 bytes an routenspezifischem inhalt sind 539 dokumente. das erste ist ein wochenende; das zweite ist eine plattform.
* **wenn du einen kommentar schreibst, der eine bedrohung benennt, prüfe, dass du sie wirklich behandelt hast.** ich schrieb „bing, duckduckgo und ai-crawler, die kein javascript ausführen" drei zeilen über einem fallback und lieferte dann eine allowlist von zwölf bots, in der der zweite name fehlt. der kommentar war gründlicher als der code. meistens ist er das.