Headless zu gehen zerschießt deine Sitemap, und zwar leise.
Dein CMS erzeugt weiter eine. Sie ist weiter gültiges XML. Sie ist nur voller falscher URLs — der API-Endpunkte, an denen der Inhalt liegt, statt der Seiten, die ein Besucher tatsächlich sieht. Und nichts warnt dich, denn aus Sicht des CMS ist alles in Ordnung: Es listet seine eigenen Seiten, genau wofür es gebaut wurde.
Das eigentliche Problem
In einem Headless-Setup liegt der Inhalt an einem Ort und die Seiten an einem anderen. Das CMS kennt /blogapi/warum-ich-ddev-nicht-nutze. Besucher und Google sehen example.com. Das sind verschiedene Hosts, verschiedene Pfad-Präfixe und verschiedene Antworttypen — eines liefert JSON, das andere eine Seite.

Reich die vom CMS erzeugte Sitemap ein, und du sagst Google, es soll eine JSON-API indexieren. Im besten Fall ignoriert es sie. Realistisch bekommst du JSON-Endpunkte im Index, Duplicate Content gegen deine echten Seiten und ein Crawl-Budget, das für URLs draufgeht, die nie öffentlich hätten sein sollen.
Bleiben drei Möglichkeiten. Die Sitemap von Hand pflegen — bei zehn Seiten in Ordnung, bei vierzig falsch. Sie im Frontend erzeugen — vertretbar, wenn das Frontend ein Framework mit Sitemap-Unterstützung ist, aber es muss zur Build-Zeit die komplette Inhaltsliste holen und weiß nur, was die API ihm erzählt. Oder sie mit einem Console-Command aus dem CMS erzeugen, was ich mache, weil das CMS das Einzige ist, das die volle Wahrheit kennt: jede Seite, ihren Veröffentlichungsstatus und wann sie zuletzt geändert wurde.
Der Command
Zwei Aufgaben: den Seitenbaum lesen und jeden Eintrag in eine Frontend-URL übersetzen.
#[AsCommand(
name: 'app:sitemap:generate',
description: 'Write sitemap.xml with the public frontend URLs',
)]
class GenerateSitemapCommand extends Command
{
public function __construct(
private readonly PageRepository $pages,
private readonly string $frontendBaseUrl,
private readonly string $publicDir,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$entries = $this->pages->findPublished();
$xml = new \XMLWriter();
$xml->openMemory();
$xml->startDocument('1.0', 'UTF-8');
$xml->startElement('urlset');
$xml->writeAttribute('xmlns', 'http://www.sitemaps.org/schemas/sitemap/0.9');
foreach ($entries as $page) {
$xml->startElement('url');
$xml->writeElement('loc', $this->frontendUrl($page));
$xml->writeElement('lastmod', $page->getUpdatedAt()->format('Y-m-d'));
$xml->writeElement('priority', $page->getPriority());
$xml->endElement();
}
$xml->endElement();
$xml->endDocument();
file_put_contents($this->publicDir . '/sitemap.xml', $xml->outputMemory());
$output->writeln(sprintf('%d URLs written', count($entries)));
return Command::SUCCESS;
}
}
Nimm XMLWriter, keine String-Verkettung. Ein Seitentitel mit einem kaufmännischen Und darin produziert ungültiges XML, und eine ungültige Sitemap wird komplett abgelehnt statt teilweise — du verlierst jede URL, nicht die eine kaputte.
Die URL-Übersetzung ist der eigentliche Punkt
Alles andere ist Boilerplate. Das hier trägt das eigentliche Wissen:
private function frontendUrl(Page $page): string
{
$slug = ltrim($page->getSlug(), '/');
$slug = preg_replace('#^blogapi/#', 'blog/', $slug);
return rtrim($this->frontendBaseUrl, '/') . '/' . $slug;
}
Halte es in einer Methode, und halte die Basis-URL in der Konfiguration statt als Konstante. Die Zuordnung zwischen "wo der Inhalt gespeichert ist" und "wo die Seite ausgeliefert wird" ist das Einzige an einem Headless-Setup, das nur in deinem Kopf existiert — und es in eine einzige benannte Funktion zu packen ist die Art, wie du verhinderst, dass es an vier Stellen dupliziert wird und drei davon veraltet sind.
Vier Dinge, die beim ersten Mal falsch sein werden
Unveröffentlichte Seiten. Die Abfrage muss auf den Veröffentlichungsstatus filtern, und in den meisten CMS gibt es mehr als ein Flag — versteckt, gelöscht, geplantes Start- und Enddatum, Zugriffsbeschränkungen. Übersieh eines, und du veröffentlichst die URL eines Entwurfs. In diesem Blog liegen Entwürfe im Seitenbaum direkt neben Live-Beiträgen, der Filter ist also das Einzige zwischen einem unfertigen Artikel und Google.
lastmod, das lügt. Nimm das echte Änderungsdatum des Inhalts. Ein verbreiteter Fehler ist, bei jedem Lauf das heutige Datum zu schreiben — was Suchmaschinen, wenn der Command auf einem Cronjob läuft, erzählt, dass sich jede Seite der Website täglich ändert. Sie merken ziemlich schnell, dass das nicht stimmt, und dann wird dem Feld gar nichts mehr geglaubt.
Seiten ohne Inhalt. Ordner, Weiterleitungen, Verweise, Systemseiten. Sie stehen im Seitenbaum und sind keine URLs, auf denen jemand landen sollte. Filtere nach Seitentyp, nicht nur nach Veröffentlichungsstatus.
Das 50.000er-Limit. Eine Sitemap fasst 50.000 URLs oder 50 MB unkomprimiert. Darüber brauchst du einen Sitemap-Index, der auf mehrere Dateien zeigt. Die meisten Seiten kommen nie dorthin — aber wenn deine es könnte, bau es früh ein, denn es nachzurüsten heißt zu ändern, was du eingereicht hast.
Zum richtigen Zeitpunkt ausführen
Nicht nach Zeitplan. Bei Veröffentlichung.
Ein nächtlicher Cronjob heißt, dass ein neuer Beitrag bis zu einen Tag lang in der Sitemap fehlt, und die Sitemap ist eines der wenigen Dinge, bei denen Schnelligkeit wirklich hilft. Häng den Command an das, was beim Veröffentlichen von Inhalten feuert — in TYPO3 ist das ein DataHandler-Hook, in Symfony ein Doctrine-Event oder eine Message aus deinem Publishing-Flow.
Behalte trotzdem einen geplanten Lauf als Sicherheitsnetz. Hooks werden verpasst; eine nächtliche Neuerzeugung fängt die Abweichung ab, und wenn die beiden je unterschiedliche Ausgaben produzieren, hast du etwas über deinen Publishing-Flow gelernt.
Bei Veröffentlichung neu erzeugen für Aktualität, nach Zeitplan für Korrektheit. Die beiden fangen verschiedene Fehler.
Dann noch das Offensichtliche prüfen
Zeig mit der robots.txt darauf, und zwar auf der Frontend-Domain:
Sitemap: example.com
Und stell sicher, dass dein API-Host überhaupt nicht indexierbar ist — er sollte Disallow: / liefern und idealerweise gar nicht öffentlich erreichbar sein. Ein Headless-Setup, dessen API crawlbar ist, gibt dir zwei Kopien jedes Inhalts, und die Suchmaschine entscheidet, welche kanonisch ist.
Letzte Sache, und die wird dich wirklich beißen: Wenn ein Cache vor der Seite sitzt, ist die neue sitemap.xml unsichtbar, bis du ihn leerst. Ich betreibe ein Blog hinter Varnish mit einer sehr langen TTL, und ich habe definitiv schon eine Sitemap neu erzeugt, sie abgerufen, die alte gesehen und dann einen Bug im Command gesucht, den es nicht gab. Ruf sie mit einem Cache-Busting-Query-String ab, bevor du irgendetwas schlussfolgerst.