Wie alles genau funktioniert, was geschichtlich und technisch dahintersteckt, liest du hier.

Von Microdata zu JSON-LD

Strukturierte Daten haben eine längere Geschichte als viele denken. Vor schema.org gab es Microformats und RDFa – Ansätze, semantische Informationen direkt in HTML-Attribute einzubetten. Keiner davon hat sich breit durchgesetzt.

2009 kam Microdata mit HTML5: einfacher als RDFa, direkt in HTML integriert (itemprop, itemtype). Google fing an, es auszulesen. Ich hatte damals Microdata-Markup als Person auf meiner Website – es hat funktioniert, aber es hat das Markup komplexer gemacht.

2011 war der eigentliche Wendepunkt: Google, Bing, Yahoo und Yandex einigten sich auf schema.org als gemeinsames Vokabular. Parallel dazu entwickelte sich JSON-LD – 2014 W3C-Standard, 2016 Googles klare Empfehlung. Der entscheidende Vorteil: JSON-LD lebt in einem einzigen <script type="application/ld+json">-Block im <head>, komplett getrennt vom HTML-Markup.

Warum sind strukturierte Daten heute wichtiger denn je?

Suchmaschinen haben immer versucht, Inhalte zu verstehen. Aber moderne Answer Engines (Google AI Overviews, Perplexity, ChatGPT Search) gehen weiter: Sie wollen nicht nur crawlen und raten, sondern direkt strukturierte Fakten. JSON-LD liefert genau das – maschinenlesbar, eindeutig, ohne HTML-Parsing.

Es ist keine neue Technologie. Aber der Kontext, in dem sie genutzt wird, hat sich verändert. Ein verwandter Ansatz in dieselbe Richtung: llms.txt für WordPress.

Warum kein Plugin?

Es gibt Plugins, die JSON-LD für WordPress generieren – Yoast SEO macht es, RankMath auch. Für Websites mit einem fertigen Theme oder Block Theme ist das die richtige Wahl.

Für ein eigenes Classic Theme reicht ein Plugin an der einen Stelle zu weit – und an der anderen nicht weit genug. Yoast und RankMath wissen nichts davon, dass mein Accordion-Block automatisch einen FAQPage-Knoten auslösen soll. Sie kennen weder meine CSS-Selektoren für Speakable noch meinen Breadcrumb-Aufbau. Was sie ausgeben, ist generisch – und generisch ist hier das Problem.

Wer das Theme kennt, kann den Graphen so bauen, wie er tatsächlich gebraucht wird: kontextspezifisch, vollständig verlinkt, ohne leere Felder, ohne doppelte Ausgabe.

Was dabei herausgekommen ist

Eine einzige PHP-Datei, die in wp_head einen <script type="application/ld+json">-Block ausgibt – einen einzigen @graph mit allen Knoten, vernetzt über stabile @id-Referenzen.

KontextKnoten
ImmerWebSite, Person
EinzelbeitragBlogPosting (mit SpeakableSpecification; plus FAQPage wenn ein Accordion vorhanden ist)
StartseiteBlog
Kategorie-/Tag-/Taxonomie-ArchivCollectionPage
Statische SeiteWebPage
Jede Seite mit mehrgliedrigem PfadBreadcrumbList

Alle Knoten sind untereinander verlinkt: BlogPosting.isPartOf > WebSite, author/publisher > Person, FAQPage.about > BlogPosting. Keine isolierten, verwaisten Snippets.

Technische Voraussetzungen

  • WordPress (Classic Theme inkl. Child Themes) – Block Themes/Full Site Editing werden nicht unterstützt
  • PHP 8.0+
  • Automatische FAQPage-Generierung erfordert den Core Accordion-Block (WordPress 6.9+)

Umsetzung Schritt für Schritt

Der Code verteilt sich auf mehrere Bereiche: hentry-Entfernung, Hilfsfunktionen, FAQ-Extraktion aus dem Accordion-Block und die Hauptfunktion mit dem @graph. Jeder Abschnitt ist eigenständig – wer nur Teile übernehmen will, findet die Grenzen klar.

Hinweis: Im gesamten Code steht yourtheme_ als Platzhalter für den eigenen Theme-Präfix, YOURTHEME_ für Konstanten. Beides konsequent durch den eigenen Namespace ersetzen – z.B. mytheme_ respektive MYTHEME_.

hentry entfernen

hentry ist ein Microformat – der Klassenname selbst ist der @type. Crawler lesen strukturierte Daten direkt aus HTML-Klassenattributen, und solange hentry an den Post-Elementen hängt, schickt WordPress implizit veraltetes Microformat-Markup mit. Wenn JSON-LD die Ausgabe strukturierter Daten übernimmt, ist das redundant.

add_filter( 'post_class', function( $classes ) {
    if ( apply_filters( 'yourtheme_jsonld_remove_hentry', true ) ) {
        return array_diff( $classes, array( 'hentry' ) );
    }
    return $classes;
} );

Daher wird es entfernt – WordPress nutzt hentry nicht für Styling, visuell ändert sich nichts. Wer es trotzdem behalten will, setzt den Filter auf false.

Hilfsfunktionen

Drei Hilfsfunktionen liefern die Daten, welche mehrere Knoten gemeinsam nutzen.

Beschreibung (yourtheme_jsonld_get_description) mit einer Fallback-Kette: Excerpt bei Einzelbeiträgen, Termbeschreibung bei Archiven. Das Ergebnis läuft durch den yourtheme_jsonld_description-Filter – SEO-Plugins wie Yoast oder Essential SEO können ihre Beschreibung dort einspeisen.

Social Links (yourtheme_jsonld_get_social_links) – standardmässig leer, über yourtheme_jsonld_person_social mit einem Array von Profil-URLs befüllbar. Diese landen im sameAs-Feld des Person-Knotens.

Breadcrumb-Items (yourtheme_jsonld_get_breadcrumb_items) – leitet die Pfad-Elemente aus dem aktuellen Kontext ab (Home > Kategorie > Beitragstitel) und gibt sie als Array zurück. Überschreibbar über yourtheme_jsonld_breadcrumb_items.

Alle drei Hilfsfunktionen sind mit function_exists gesichert und vollständig filterbar. Wer die Logik anpassen will, setzt einen add_filter() in der functions.php – die Datei json-ld.php selbst muss nicht angefasst werden.

FAQ aus dem Accordion-Block

Dies ist der technisch interessanteste Teil der Implementierung. Wichtig ist dabei folgende Voraussetzung:

Der Core Accordion-Block (core/accordion, WordPress 6.9+) wird ausschliesslich für FAQ-Abschnitte eingesetzt. Seine blosse Präsenz in einem Beitrag löst automatisch einen FAQPage-Knoten aus – ohne zusätzliches Markup beim Schreiben setzen zu müssen.

Die Extraktion läuft rein serverseitig:

$accordion = yourtheme_jsonld_find_block_by_name(
    parse_blocks( $post->post_content ),
    $block_name
);
$html = render_block( $accordion );

$dom = new DOMDocument();
libxml_use_internal_errors( true );
$dom->loadHTML( '<?xml encoding="UTF-8" ?>' . $html );
libxml_clear_errors();

$xpath = new DOMXPath( $dom );

Ablauf: parse_blocks() durchsucht den post_content nach dem Accordion-Block – namespace-agnostisch über den Basisnamen, womit core/accordion, mytheme/accordion und jeder andere Namespace funktionieren. render_block() rendert das vollständige Core-HTML serverseitig. DOMDocument/DOMXPath extrahiert anschliessend pro .wp-block-accordion-item die Frage aus .wp-block-accordion-heading__toggle-title und die Antwort aus .wp-block-accordion-panel.

Der FAQPage-Knoten ist eigenständig (#faq) und über about mit dem BlogPosting sowie über isPartOf mit der WebSite verlinkt.

Warum rein serverseitig? Das Theme kann den Accordion-Block so clientseitig z.B. zu einem Bootstrap Collapse umbauen. Für den JSON-LD-Parser ist das dann irrelevant – er liest das Core-Markup, das der Server ausliefert, nicht das umgebaute DOM im Browser. Das eröffnet die volle Gestaltungsfreiheit beim Accordion im Frontend.

Kein Accordion im Beitrag > kein FAQPage-Knoten, kein Fehler. Wer den Accordion-Block auch für andere Inhalte nutzen will, steuert die Erkennung über den yourtheme_jsonld_faq_block-Filter.

Hinweis zu FAQ-Rich-Results: Google hat FAQ-Rich-Results vollständig eingestellt – nach einer Einschränkung auf Health- und Government-Content im August 2023 folgte die vollständige Abschaffung im Mai 2026. Für das LLM- und Answer-Engine-Parsing bleibt der strukturierte FAQ-Knoten trotzdem wertvoll – er liefert die Q&A-Struktur explizit, ohne dass ein Modell sie aus dem Fliesstext extrahieren muss.

Den Graphen zusammensetzen

Die Hauptfunktion yourtheme_jsonld_output() setzt den gesamten Graphen zusammen und hängt ihn via add_action( 'wp_head', 'yourtheme_jsonld_output' ) in den Head.

Zwei Knoten erscheinen immer: WebSite und Person. Alle seitenspezifischen Werte – Name, URL, @id der Person – kommen über Filter, damit die Datei ohne Editieren wiederverwendbar bleibt:

$person_name = apply_filters( 'yourtheme_jsonld_person_name', get_bloginfo( 'name' ) );
$person_url  = apply_filters( 'yourtheme_jsonld_person_url',  $base_url );
$person_id   = apply_filters( 'yourtheme_jsonld_person_id',   $base_url . '/#person' );

Der Fallback für person_name ist der Website-Name – bei einer persönlichen Website mit dem eigenen Namen ist das oft korrekt, bei «Firma Blog» offensichtlich falsch. Der Filter sollte unbedingt gesetzt werden. Für den Einsatz auf Unternehmenswebsites gibt es einen Hinweis im README und ein paar Zeilen weiter unten in diesem Beitrag.

Der BlogPosting-Knoten enthält alle relevanten Felder: headline, datePublished, dateModified, description, image (nur wenn ein echtes Beitragsbild vorhanden ist – kein leeres Feld), articleSection (Kategorien), keywords (Tags), author, publisher, inLanguage, isPartOf, mainEntityOfPage sowie speakable:

'speakable' => array(
    '@type'       => 'SpeakableSpecification',
    'cssSelector' => apply_filters(
        'yourtheme_jsonld_speakable_selectors',
        array( '.post-header h1', '.post-excerpt' )
    ),
),

SpeakableSpecification markiert Titel und Lead als vorlesbar für Answer Engines. Die CSS-Selektoren müssen dem tatsächlichen Theme-Markup entsprechen – und das Excerpt muss als sichtbares On-Page-Element gerendert sein, nicht nur als Meta-Feld.

Am Ende der Funktion steht der finale Escape Hatch:

$graph = apply_filters( 'yourtheme_jsonld_graph', $graph );

Wer einzelne Knoten anpassen, entfernen oder ergänzen will, ohne die Datei zu editieren, kann hier eingreifen. Beispiel – Person in Organization umwandeln:

add_filter( 'yourtheme_jsonld_graph', function( $graph ) {
    foreach ( $graph as &$node ) {
        if ( isset( $node['@type'] ) && 'Person' === $node['@type'] ) {
            $node['@type'] = 'Organization';
            $node['logo']  = home_url( '/logo.png' );
        }
    }
    return $graph;
} );

Der vollständige Code

Der vollständige Code liegt in einem öffentlichen Repository auf GitHub – inklusive README, Changelog und Lizenz.


Integration ins Theme

Die Datei liegt dann z.B. unter inc/json-ld.php und wird in der functions.php eingebunden:

require get_template_directory() . '/inc/json-ld.php';

Kein Plugin. Kein Build-Step. Die Ausgabe erfolgt via wp_head und ist sofort aktiv.

Anpassen an dein Setup

Wer die Lösung in ein eigenes Theme integriert, muss folgendes anpassen:

  • Funktionspräfix yourtheme_ und Konstantenpräfix YOURTHEME_ durch den eigenen Namespace ersetzen
  • Mindestens yourtheme_jsonld_person_name und yourtheme_jsonld_person_social setzen – die Person-Identität ist das Fundament des gesamten Graphen
  • yourtheme_jsonld_speakable_selectors anpassen – die Defaults (.post-header h1, .post-excerpt) sind auf mein Theme zugeschnitten und müssen an die verwendete Struktur (h1 und Excerpt) des jeweiligen Themes angepasst werden
  • YOURTHEME_JSONLD_SEPARATOR (Standard: |) – Trennzeichen in Seiten- und Archivnamen

Vollständige Filter-Referenz:

Filter / KonstanteStandardZweck
yourtheme_jsonld_person_nameSite-NamePerson name
yourtheme_jsonld_person_urlHome-URLPerson url
yourtheme_jsonld_person_id{home}/#personPerson @id (stabil halten)
yourtheme_jsonld_person_social[]Profil-URLs > sameAs
yourtheme_jsonld_descriptionExcerpt / TermbeschreibungSEO-Plugin-Beschreibung einspeisen
yourtheme_jsonld_speakable_selectors['.post-header h1', '.post-excerpt']CSS-Selektoren für Speakable
yourtheme_jsonld_faq_blockaccordionBlockbasisname für FAQ-Extraktion
yourtheme_jsonld_blog_urlPosts-Seite oder HomeURL-Basis für Blog-Knoten
yourtheme_jsonld_breadcrumb_itemsabgeleitetBreadcrumb-Pfad anpassen
yourtheme_jsonld_remove_hentrytrueLegacy hentry-Klasse entfernen
yourtheme_jsonld_graphvollständiger GraphFinaler Escape Hatch
YOURTHEME_JSONLD_SEPARATOR (Konstante)|Trennzeichen in Seiten-/Archivnamen

Validierung

Das Markup lässt sich mit diesen Tools prüfen:

FAQ

Nein. Die Lösung setzt auf functions.php und Classic-Theme-Mechanismen. Mit Block Themes funktioniert das nicht ohne grössere Anpassungen.

Mit Vorsicht: Yoast gibt ebenfalls JSON-LD aus. Beide Ausgaben laufen dann parallel, was zu doppelten oder widersprüchlichen Knoten führen kann. Entweder die JSON-LD-Ausgabe in Yoast deaktivieren – oder auf diese Lösung verzichten und Yoast alleine arbeiten lassen.

Wer nur die Beschreibung aus Yoast übernehmen will:

add_filter( 'yourtheme_jsonld_description', function( $description ) {
    if ( function_exists( 'YoastSEO' ) ) {
        $meta = YoastSEO()->meta->for_current_page()->description;
        if ( $meta ) {
            return $meta;
        }
    }
    return $description;
} );

Valide heisst zulässig – garantiert ausgespielt, aber nicht zwingend als Rich Results umgesetzt. Google und andere Suchmaschinen entscheiden selbst, ob und welche Rich Results aus dem Markup generiert werden.

Google hat FAQ-Rich-Results in der SERP seit Mai 2026 vollständig eingestellt. Für das LLM- und Answer-Engine-Parsing bleibt das Markup unabhängig davon wertvoll.

Ja – über den yourtheme_jsonld_faq_block-Filter lässt sich der Blockname ändern oder die FAQ-Erkennung vollständig deaktivieren:

// FAQ-Erkennung komplett deaktivieren
add_filter( 'yourtheme_jsonld_faq_block', fn() => '' );