WordPress Schema Markup at Scale: Automating Rich Results for Large Sites

Manually adding JSON-LD schema to every post does not scale beyond a few dozen pages. A large WordPress site — a recipe blog with 2000 recipes, a job board with 500 listings, or a news site publishing 20 articles per day — needs schema generated automatically from post meta, taxonomy data, and custom fields, with the correct @type determined by post type and category. Google’s Rich Results Test and the Search Console Coverage report provide feedback on schema correctness at scale.


The code below implements a schema generator class that outputs the correct JSON-LD for posts (Article), pages (WebPage), products (Product), recipes (Recipe), and FAQs (FAQPage) based on post type and meta, with a filter system that lets other plugins extend the schema output.


<?php
class My_Schema_Generator {

    public static function init(): void {
        add_action( 'wp_head', [ __CLASS__, 'output_schema' ] );
    }

    public static function output_schema(): void {
        $schema = self::build_schema();
        if ( ! $schema ) return;

        printf(
            '<script type="application/ld+json">%s</script>' . PHP_EOL,
            wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT )
        );
    }

    private static function build_schema(): array|null {
        if ( is_front_page() ) return self::website_schema();
        if ( is_singular( 'post' ) ) return self::article_schema();
        if ( is_singular( 'page' ) ) return self::webpage_schema();
        if ( is_singular( 'recipe' ) ) return self::recipe_schema();
        if ( function_exists( 'is_product' ) && is_product() ) return self::product_schema();
        if ( is_category() || is_tag() ) return self::collection_page_schema();
        return null;
    }

    private static function website_schema(): array {
        return [
            '@context' => 'https://schema.org',
            '@type'    => 'WebSite',
            'name'     => get_bloginfo( 'name' ),
            'url'      => home_url( '/' ),
            'potentialAction' => [
                '@type'       => 'SearchAction',
                'target'      => [ '@type' => 'EntryPoint', 'urlTemplate' => home_url( '/?s={search_term_string}' ) ],
                'query-input' => 'required name=search_term_string',
            ],
        ];
    }

    private static function article_schema(): array {
        $cats    = get_the_category();
        $section = $cats ? $cats[0]->name : '';

        $schema = [
            '@context'        => 'https://schema.org',
            '@type'           => 'Article',
            'headline'        => get_the_title(),
            'description'     => get_the_excerpt() ?: wp_trim_words( get_the_content(), 30 ),
            'datePublished'   => get_the_date( 'c' ),
            'dateModified'    => get_the_modified_date( 'c' ),
            'image'           => get_the_post_thumbnail_url( null, 'large' ) ?: '',
            'url'             => get_permalink(),
            'articleSection'  => $section,
            'wordCount'       => str_word_count( wp_strip_all_tags( get_the_content() ) ),
            'author'          => [
                '@type' => 'Person',
                'name'  => get_the_author(),
                'url'   => get_author_posts_url( get_the_author_meta( 'ID' ) ),
            ],
            'publisher' => [
                '@type' => 'Organization',
                'name'  => get_bloginfo( 'name' ),
                'logo'  => [ '@type' => 'ImageObject', 'url' => get_site_icon_url( 512 ) ],
            ],
        ];

        // FAQ schema: check for FAQ blocks in content
        $faq_items = self::extract_faq_items();
        if ( $faq_items ) {
            $schema['@type'] = 'FAQPage';
            $schema['mainEntity'] = $faq_items;
        }

        // Allow plugins/themes to modify the schema
        return apply_filters( 'my_article_schema', $schema, get_the_ID() );
    }

    private static function extract_faq_items(): array {
        $content = get_the_content();
        preg_match_all(
            '/<details[^>]*>.*?<summary[^>]*>(.*?)<\/summary>(.*?)<\/details>/si',
            $content, $matches, PREG_SET_ORDER
        );
        return array_map( fn( $m ) => [
            '@type'          => 'Question',
            'name'           => wp_strip_all_tags( $m[1] ),
            'acceptedAnswer' => [ '@type' => 'Answer', 'text' => wp_strip_all_tags( $m[2] ) ],
        ], $matches );
    }

    private static function recipe_schema(): array {
        $id  = get_the_ID();
        return apply_filters( 'my_recipe_schema', [
            '@context'       => 'https://schema.org',
            '@type'          => 'Recipe',
            'name'           => get_the_title(),
            'image'          => get_the_post_thumbnail_url( $id, 'large' ) ?: '',
            'prepTime'       => 'PT' . (int) get_post_meta( $id, 'prep_time_minutes', true ) . 'M',
            'cookTime'       => 'PT' . (int) get_post_meta( $id, 'cook_time_minutes', true ) . 'M',
            'recipeYield'    => get_post_meta( $id, 'servings', true ),
            'recipeCategory' => implode( ', ', wp_get_post_terms( $id, 'recipe_category', [ 'fields' => 'names' ] ) ),
        ], $id );
    }

    private static function collection_page_schema(): array {
        return [ '@context' => 'https://schema.org', '@type' => 'CollectionPage',
                 'name' => single_cat_title( '', false ), 'url' => get_term_link( get_queried_object() ) ];
    }

    private static function webpage_schema(): array {
        return [ '@context' => 'https://schema.org', '@type' => 'WebPage',
                 'name' => get_the_title(), 'url' => get_permalink(),
                 'dateModified' => get_the_modified_date( 'c' ) ];
    }

    private static function product_schema(): array { return []; }
}

My_Schema_Generator::init();


NOTE: Google ignores JSON-LD schema that is injected into the page after the initial HTML response via JavaScript (e.g., rendered by a React component) — the schema must be present in the server-rendered HTML that Googlebot receives on its initial crawl; always output JSON-LD from wp_head as a server-rendered <script> tag, not from a client-side script that runs after page load, and verify with the URL Inspection tool in Google Search Console rather than just the Rich Results Test (which executes JavaScript).