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).